capistrano-docker_cluster 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b21dd5be728c678022ef6ae1b2bc6034e8610cac1c2152fd3cde26e852e0bc35
4
+ data.tar.gz: 4a297e06578a06670daab133c6dca12c19d3b2596a82d9f26605d1391adade49
5
+ SHA512:
6
+ metadata.gz: 5fc892bf4d56e78fabec0603b8e03ae1edbf151d54753fc46abc71d2a5d0272fd46b5612da053c2e6925a7d7e560fa99d024aea4f26bc57c8456236cd38728c1
7
+ data.tar.gz: 5f903a514ea9366f8f4d502b0b32c2cbe821be92edd8344e9d74519cd19346f09c8a348de8723045bd429b1e5c2106ccfe2b3507cf4f5b5cd3d5c2787d78ff58
data/CHANGE_LOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # 1.0.1
2
+
3
+ * Fix module naming convention to match gem name.
4
+
5
+ # 1.0.0
6
+
7
+ * Initial release
data/MIT_LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Brian Durand
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # Capistrano Docker Cluster
2
+
3
+ This gem provides a recipe to use capistrano to deploy docker applications.
4
+
5
+ This allows you to deploy an application across a cluster of servers running docker. There are, of course, other methods of doing this (kubernetes, docker-compose, etc.). This method can fill a nitch of allowing you to dockerize your application but keep the deployment simple and use existing configs if you're already deploying via capistrano.
6
+
7
+ You application will be deployed by pulling a tag from a docker repository on the remote servers and then starting it up as a cluster using the `bin/docker_cluster` script. This script will start a cluster of docker containers by spinning up a specified number of containers from the same image and configuration. It does this gracefully by first shutting down any excess containers and then restarting the containers one at a time. The script can perform an optional health check to determine if a container is fully running before shutting down the next contaner.
8
+
9
+ If you specify a port mapping for the containers, the container ports will be mapped to incrementing host ports so you can run multiple server containers fronted by a load balancer.
10
+
11
+ The full set of arguments can be found in the `bin/docker-cluster` script.
12
+
13
+ ## Configuration
14
+
15
+ The deployment is configured with the following properties in your capistrano recipe.
16
+
17
+ * `docker_repository` - The URI for the repository where to pull images from. If you are building images on the docker host (i.e. for a staging server), this can just be the local respoitory.
18
+
19
+ * `docker_tag` - The tag of the image to pull for starting the containers.
20
+
21
+ * `docker_roles` - List of server roles that will run docker containers. This defaults to `:docker`, but you can change it to whatever server roles you have in your recipe.
22
+
23
+ * `docker_user` - User to use when running docker commands on the remote host. This user must have access to the docker daemon. Default to the default capistrano user.
24
+
25
+ * `docker_env` - Environment variables needed to run docker commands. You may need to set `HOME` if you are pulling docker images from a remote repository using a use that is not the default deploy user.
26
+
27
+ * `docker_apps` - List of apps to deploy. Each app is deployed to its own containers with its own configuration. This value should usually be defined on a server role.
28
+
29
+ * `docker_prefix` - Optional prefix to attach to docker container names. This can be used to distinguish containers where multiple applications are running on the same host with the same `docker_apps` names (for instance, if you run staging containers on the same hardware as your production containers).
30
+
31
+ * `docker_configs` - List of global configuration files for starting all containers.
32
+
33
+ * `docker_app_configs` - Map of configuration files for starting specific docker apps.
34
+
35
+ * `docker_args` - List of global command line arguments for all continers.
36
+
37
+ * `docker_app_args` - Map of command line arguments for starting specific docker apps.
38
+
39
+ ### Example Configuration
40
+
41
+ ```ruby
42
+ set :docker_repository, repository.example.com/myapp
43
+
44
+ set :docker_tag, ENV.fetch("tag")
45
+
46
+ # Define two apps; the web app will run on both server01 and server02
47
+ role :web, [server01, server02], user: 'app', docker_apps: [:web]
48
+ role :async, [server01], user: 'app', docker_apps: [:async]
49
+
50
+ # These configuration files will apply to all containers
51
+ set :docker_configs, ["config/volumes.properties"]
52
+
53
+ # Unlike config files, args can be dynamically generated at runtime
54
+ set :docker_args, ["--env=ASSET_HOST=#{ENV.fetch('asset_host')}"]
55
+
56
+ # These configuration files and args will apply only to each app.
57
+ set :docker_app_configs, {
58
+ web: ["config/web.properties"],
59
+ async: ["config/async.properties"]
60
+ }
61
+
62
+ set :docker_app_args, {
63
+ web: ["--env=SERVER_HOST=#{fetch(:server_host)}"]
64
+ }
65
+
66
+ # If your capistrano user doesn't have access to the docker daemon, you can specify a different user.
67
+ set :docker_user, "root"
68
+
69
+ # You can also specify environment variables that may be needed for running docker commands.
70
+ set :docker_env, {"HOME" => "/root"}
71
+ ```
72
+
73
+ ## Server Scripts
74
+
75
+ Scripts to control your docker applications are put into the `bin` directory in the capistrano target directory. These scripts are wrappers around the `bin/docker-cluster` script and supply the configuration values to that script for each app you've defined.
76
+
77
+ ### bin/start
78
+
79
+ ```bash
80
+ bin/start app [additional arguments]
81
+ ```
82
+
83
+ * The start script will start up the docker containers for you app.
84
+ * If the containers are not running, they will be started.
85
+ * If excess containers are running, they will be stopped.
86
+ * If the containers are running, but they are running on a different image version, they will be replaced one at a time by new containers.
87
+
88
+
89
+ ### bin/stop
90
+
91
+ ```bash
92
+ bin/stop app
93
+ ```
94
+
95
+ * All the containers associated with the app will be stopped.
96
+
97
+ ### bin/run
98
+
99
+ ```bash
100
+ bin/run app [additional arguments]
101
+ ```
102
+
103
+ * Start a one off container with the specified app config.
104
+ * The normal port mapping used for the cluster will not be included; if you want to expose ports, you'll need to supply the port mapping.
105
+ * All apps defined in `docker_apps` as well as `docker_app_configs` and `docker_app_args` will be included as apps. This allows you to set up things like a "console" app which will open a console on a container for debugging, etc. without having it be part of the cluster apps.
106
+
107
+ ## Capfile and SCM setting
108
+
109
+ The deployment does not require a source control management system to perform the build. To turn off this feature you need to include this in your project's Capfile to include the Docker deployment recipe and turn off the default (git) SCM setting for capistrano.
110
+
111
+ ```ruby
112
+ require 'capistrano/docker_cluster'
113
+ install_plugin Capistrano::Scm::None::Plugin
114
+ ```
115
+
116
+ ## Remote Repository
117
+
118
+ If your `docker_repository` points to a remote repository, then the tag specified by `docker_tag` will be pulled from that repository during the deploy. If the repository requires authentication, then you should implement the `docker:authenticate` task to authenticate all servers in the `docker_role` role with the repository. You can use the `as_docker_user` method to run docker commands as the user specified in `docker_user`.
119
+
120
+ ### Example Authentication with Amazon ECR
121
+
122
+ ```ruby
123
+ namespace :docker do
124
+ task :authenticate do
125
+ bin_dir = "#{fetch(:release_path)}/bin"
126
+ ecr_login_path = "#{bin_dir}/ecr_login"
127
+ on release_roles(fetch(:docker_roles)) do |host|
128
+ execute(:mkdir, "-p", bin_dir)
129
+ upload! StringIO.new(ecr_login_script), ecr_login_path
130
+ as_docker_user do
131
+ execute :bash, ecr_login_path
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # Script to run the aws ecr get-login results, but with passing the password in
138
+ # via STDIN so that it doesn't appear in the capistrano logs.
139
+ def ecr_login_script
140
+ <<~BASH
141
+ #!/usr/bin/env bash
142
+
143
+ set -o errexit
144
+
145
+ read -sra cmd < <(/usr/bin/env aws ecr get-login --no-include-email)
146
+ pass="${cmd[5]}"
147
+ unset cmd[4] cmd[5]
148
+ /usr/bin/env "${cmd[@]}" --password-stdin <<< "$pass"
149
+ BASH
150
+ end
151
+ ```
152
+
153
+ ## Building Docker Image
154
+
155
+ If you need to build the docker image on the remote host as part of the deploy (for example if you're deploying pre-release code to a staging server), you can implement the `docker:build` task to build your docker image. You must also tag the image with the value in the `:docker_tag` property.
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Script to manage a cluster of docker containers with the same configuration running on sequential ports.
4
+ #
5
+ # The --name parameter will be used to assign names to the containers as "name.number" where number is the
6
+ # number of the started container starting at 1.
7
+ #
8
+ # The --count paramter can be used to specify the number of containers to start up. If the script finds that
9
+ # more than that number of containers are already running, it will first shut down the excess containers.
10
+ # It will then shutdown the running containers one at a time and start up a new one before shutting down the
11
+ # next container.
12
+ #
13
+ # The --image parameter specifies the docker image to start up. It can be either a tag or an image id. This
14
+ # parameter is required if --count is greater than zero.
15
+ #
16
+ # The --port paramters specifies port mapping in the form "container_port:base_host_port" where container
17
+ # port is the exposed port to map from the containers. The base host port is the port number to start mapping
18
+ # the container ports to. If the base host port is not specified, then the container port will be used as the
19
+ # base host port. For instance, `--port=80:8000 --count=2` will start two containers with the first one mapping
20
+ # host port 8000 to container port 80 and the second mapping host port 8001 to container port 80.
21
+ #
22
+ # The --hostname parameter can be used to specify a base host name for the containers. The host name for each
23
+ # container will be "container_name.base_host_hamer" where container name uses a hyphen instead of a period as
24
+ # the delimiter.
25
+ #
26
+ # The --healthcheck parameter can be used to specify either a command to run inside the container or a URL
27
+ # to ping from within the container to determine if the container is up. The next container will not be shutdown
28
+ # until the previous one is determined to be up when this parameter is specified.
29
+ #
30
+ # The --timeout parameter can be used to specify a timeout for how long to wait for a container to stop or start
31
+ # before assuming something is wrong. In the case of stopping the container, the container will be force killed
32
+ # after the specified number of seconds. If starting a container times out, then the restart script will be stopped
33
+ # at that point and no more containers will be restarted.
34
+ #
35
+ # The --config parameter can be used to specify a file that contains more command line arguments for this script. This
36
+ # parameter can be specified multiple times. The files should be property files in the form "arg=val" or "arg val" or "arg"
37
+ # on each line. Comments can be used in the configuration files using "#".
38
+ #
39
+ # The --command parameter can be used to specify the command each docker container should run. If multiple command parameters
40
+ # are specified, they will be concatenated together. You can use this to specify a command and arguments separately.
41
+ #
42
+ # The --force parameter can be used to specify that containers should always be restarted. The default behavior
43
+ # is to only restart containers if they are not running the specified image.
44
+ #
45
+ # The --one-off parameter can be used to specify that a one off container should be spun up instead of stopping
46
+ # and starting containers in the cluster. Any --port, --hostname, --healthcheck, and --name arguments that appear
47
+ # before this argument will be ignored. If these arguments appear after the --one-off argument, then they will
48
+ # be passed through to the `docker run` command.
49
+ #
50
+ # The --verbose parameter can be used for debugging and will show all the shell commands as they are run.
51
+ #
52
+ # All other parameters are passed through to the `docker run` command.
53
+
54
+ set -o errexit
55
+
56
+ usage() {
57
+ script_name=$(basename $0)
58
+ echo "Usage: $script_name"
59
+ echo " --name CONTAINER_NAME_PREFIX (or --one-off)"
60
+ echo " [--count CONTAINER_COUNT] (default 1)"
61
+ echo " [--image DOCKER_IMAGE] (image tag or id; required if --count > 0)"
62
+ echo " [--port CONTAINER_PORT[:BASE_HOST_PORT]]"
63
+ echo " [--hostname CONTAINER_BASE_HOST_NAME]"
64
+ echo " [--healthcheck COMMAND|CURL_URL]"
65
+ echo " [--timeout SECONDS] (default 120)"
66
+ echo " [--config CONFIG_FILE_PATH]"
67
+ echo " [--command DOCKER_RUN_COMMAND]"
68
+ echo " [--verbose]"
69
+ echo " All other options are passed through to 'docker run'"
70
+ }
71
+
72
+ read_arguments() {
73
+ while [ "$1" != "" ]; do
74
+ typeset arg=$1
75
+ typeset val=
76
+ if [[ $arg =~ ^--?[^-]+ ]]; then
77
+ if [[ $arg =~ ^-.+= ]]; then
78
+ arg="${1%=*}"
79
+ val="${1#*=}"
80
+ fi
81
+ fi
82
+
83
+ case $arg in
84
+ --image )
85
+ [[ -z $val ]] && shift && val=$1
86
+ DOCKER_IMAGE=$val
87
+ ;;
88
+ --name )
89
+ [[ -z $val ]] && shift && val=$1
90
+ CONTAINER_NAME_PREFIX=$val
91
+ ;;
92
+ --count )
93
+ [[ -z $val ]] && shift && val=$1
94
+ CONTAINER_COUNT=$val
95
+ ;;
96
+ --port )
97
+ [[ -z $val ]] && shift && val=$1
98
+ PORT_MAPPING+=($val)
99
+ ;;
100
+ --hostname )
101
+ [[ -z $val ]] && shift && val=$1
102
+ CONTAINER_BASE_HOST_NAME=$val
103
+ ;;
104
+ --config )
105
+ [[ -z $val ]] && shift && val=$1
106
+ cat "$val" > /dev/null
107
+ typeset config_args=
108
+ IFS=$'\n\r' config_args=($(sed 's/#.*//g' "$val" | grep -v '^[[:space:]]*$' | sed 's/^ *//g' | sed -E 's/^([^-])/--\1/g' | sed -E 's/^([^ =]+) /\1=/g'))
109
+ read_arguments ${config_args[@]}
110
+ unset IFS
111
+ ;;
112
+ --command )
113
+ [[ -z $val ]] && shift && val=$1
114
+ DOCKER_RUN_COMMAND="$DOCKER_RUN_COMMAND $val"
115
+ ;;
116
+ --one-off )
117
+ ONE_OFF_CONTAINER="1"
118
+ CONTAINER_NAME_PREFIX=""
119
+ CONTAINER_BASE_HOST_NAME=""
120
+ HEALTHCHECK=""
121
+ PORT_MAPPING=()
122
+ ;;
123
+ --healthcheck )
124
+ [[ -z $val ]] && shift && val=$1
125
+ HEALTHCHECK=$val
126
+ ;;
127
+ --timeout )
128
+ [[ -z $val ]] && shift && val=$1
129
+ TIMEOUT=$val
130
+ ;;
131
+ --force )
132
+ FORCE_RESTART="1"
133
+ ;;
134
+ --help )
135
+ usage
136
+ exit
137
+ ;;
138
+ --verbose )
139
+ set -o xtrace
140
+ CMD_OUT=/dev/stdout
141
+ ;;
142
+ * )
143
+ DOCKER_RUN_ARGS="$DOCKER_RUN_ARGS $1"
144
+ esac
145
+ shift
146
+ done
147
+ }
148
+
149
+ # Return port mapping arguments for docker run. Ports are passed in to
150
+ # the command as `--port base_host_port:container_port`. The host port
151
+ # will be incremented for each container started so each container will
152
+ # be mapped to it's own host port.
153
+ docker_port_args() {
154
+ typeset port_args=
155
+ for port_info in "${PORT_MAPPING[@]}"; do
156
+ typeset split_port=
157
+ IFS=':' read -ra split_port <<< "$port_info"
158
+ unset IFS
159
+ typeset base_port=${split_port[0]}
160
+ typeset container_port=${split_port[1]}
161
+ if [[ $container_port == "" ]]; then
162
+ container_port=$base_port
163
+ fi
164
+ typeset host_port=`expr $base_port + $1 - 1`
165
+ port_args="-p $host_port:$container_port"
166
+ done
167
+ echo $port_args
168
+ }
169
+
170
+ # Run the docker health check command. Returns the exit status of
171
+ # running the command on the specified container. If the command is a
172
+ # URL, then it will be fetched with curl within the container.
173
+ docker_healthcheck() {
174
+ typeset container_id=$1
175
+ typeset cmd=$2
176
+ if [[ $cmd =~ ^https?:// ]]; then
177
+ cmd="curl --silent --fail $cmd"
178
+ fi
179
+ echo `/usr/bin/env docker exec "$container_id" $cmd > /dev/null; echo $?`
180
+ }
181
+
182
+ # Stop and remove the container specified by the passed in id.
183
+ docker_stop() {
184
+ typeset container_id=$1
185
+ echo "> sending stop to container $container_id"
186
+ /usr/bin/env docker stop $container_id > $CMD_OUT || true
187
+
188
+ end_time=(expr `date +%s` + $TIMEOUT)
189
+ while true; do
190
+ typeset running_info=$(/usr/bin/env docker ps --no-trunc --filter status=running --format "{{.ID}}" | grep -F "$container_id" | cat)
191
+ if [[ $running_info == "" ]]; then
192
+ break
193
+ fi
194
+
195
+ if [[ `date +%s` > $end_time ]]; then
196
+ exit 1
197
+ else
198
+ sleep 1
199
+ fi
200
+ done
201
+
202
+ # If the container is still running and we didn't send a KILL signal, try that now.
203
+ typeset running_info=$(/usr/bin/env docker ps --no-trunc --filter status=running --format "{{.ID}}" | grep -F "$container_id" | cat)
204
+ if [[ $running_info != "" && $STOP_SIGNAL != "SIGKILL" ]]; then
205
+ echo "> container still running; sending kill to container $container_id"
206
+ /usr/bin/env docker kill --signal=SIGKILL $container_id > $CMD_OUT
207
+ sleep 1
208
+ fi
209
+
210
+ /usr/bin/env docker container rm $container_id > $CMD_OUT
211
+ }
212
+
213
+ # Remove the specified container name from docker.
214
+ docker_remove_container_name() {
215
+ typeset container_name=$1
216
+ typeset container_info=$(/usr/bin/env docker container ls --no-trunc --format "{{.ID}};{{.Names}};" | grep -F ";$container_name;" | cat)
217
+ if [[ $container_info != "" ]]; then
218
+ typeset info_arr=
219
+ IFS=';' read -ra info_arr <<< "$container_info"
220
+ unset IFS
221
+ typeset container_id=${info_arr[1]}
222
+ /usr/bin/env docker container rm $container_id > $CMD_OUT
223
+ fi
224
+ }
225
+
226
+ # Shutdown containers that are no longer used. Detected by comparing
227
+ # the container numbers in the container name to the number of containers
228
+ # that were requested to start.
229
+ shutdown_excess_containers() {
230
+ typeset container_names=()
231
+ for i in $(seq 1 $CONTAINER_COUNT); do
232
+ container_names+=("$CONTAINER_NAME_PREFIX.$i")
233
+ done
234
+
235
+ typeset container_info=()
236
+ typeset line=
237
+ while IFS= read -r line; do
238
+ container_info+=( "$line" )
239
+ done < <( /usr/bin/env docker ps --no-trunc --format ";{{.ID}};{{.Names}};" | grep -F ";$CONTAINER_NAME_PREFIX." | cat )
240
+ unset IFS
241
+
242
+ for info in "${container_info[@]}"; do
243
+ typeset info_arr=
244
+ IFS=';' read -ra info_arr <<< "$info"
245
+ unset IFS
246
+ typeset container_id=${info_arr[1]}
247
+ typeset container_name=${info_arr[2]}
248
+ if [[ ! " ${container_names[@]} " =~ " ${container_name} " ]]; then
249
+ docker_stop $container_id
250
+ fi
251
+ done
252
+ }
253
+
254
+ # Get the image id a container is running.
255
+ container_image_id() {
256
+ typeset name="$CONTAINER_NAME_PREFIX.$1"
257
+ typeset running_info=$(/usr/bin/env docker ps --no-trunc --filter status=running --format ";{{.Image}};{{.Names}};" | grep -F ";$name;" | cat)
258
+ if [[ $running_info != "" ]]; then
259
+ typeset info_arr=
260
+ IFS=';' read -ra info_arr <<< "$running_info"
261
+ unset IFS
262
+ echo ${info_arr[1]}
263
+ fi
264
+ }
265
+
266
+ # Stop the container with the specified name if it is running.
267
+ stop_container() {
268
+ typeset name="$CONTAINER_NAME_PREFIX.$1"
269
+ typeset running_info=$(/usr/bin/env docker ps --no-trunc --format ";{{.ID}};{{.Names}};" | grep -F ";$name;" | cat)
270
+ if [[ $running_info != "" ]]; then
271
+ typeset info_arr=
272
+ IFS=';' read -ra info_arr <<< "$running_info"
273
+ unset IFS
274
+ typeset container_id=${info_arr[1]}
275
+ docker_stop $container_id
276
+ else
277
+ docker_remove_container_name "$name"
278
+ fi
279
+ }
280
+
281
+ start_container() {
282
+ typeset name="$CONTAINER_NAME_PREFIX.$1"
283
+ echo "> start container $name"
284
+ typeset port_args=$(docker_port_args $1)
285
+ typeset host_arg="${CONTAINER_NAME_PREFIX}-${1}.${CONTAINER_BASE_HOST_NAME}"
286
+ typeset docker_cmd="/usr/bin/env docker run --detach --name $name --hostname $host_arg $port_args $DOCKER_RUN_ARGS $DOCKER_IMAGE $DOCKER_RUN_COMMAND"
287
+ typeset container_id=$($docker_cmd)
288
+ if [[ $container_id == "" ]]; then
289
+ >&2 echo "container $name failed to start"
290
+ >&2 echo " command: $docker_cmd"
291
+ exit 1
292
+ else
293
+ echo "> starting container $name: $container_id"
294
+ fi
295
+
296
+ typeset success=1
297
+ typeset end_time=(expr `date +%s` + $TIMEOUT)
298
+ while true; do
299
+ typeset running_info=$(/usr/bin/env docker ps --no-trunc --filter status=running --format ";{{.ID}};{{.Names}};{{.Status}}" | grep -F ";$name;" | cat)
300
+ if [[ $running_info == "" ]]; then
301
+ >&2 echo "container $name not running when it was expected"
302
+ exit 1
303
+ fi
304
+ typeset info_arr=
305
+ IFS=';' read -ra info_arr <<< "$running_info"
306
+ unset IFS
307
+
308
+ typeset status=${info_arr[3]}
309
+ if [[ $status == "Up"* ]]; then
310
+ if [[ $HEALTHCHECK != "" ]]; then
311
+ healthy=`docker_healthcheck $container_id "$HEALTHCHECK"`
312
+ if [[ $healthy == "0" ]]; then
313
+ success=0
314
+ break
315
+ fi
316
+ else
317
+ if [[ $status == *"health"* ]]; then
318
+ if [[ $status == *"healthy"* ]]; then
319
+ success=0
320
+ break
321
+ fi
322
+ else
323
+ success=0
324
+ break
325
+ fi
326
+ fi
327
+ fi
328
+
329
+ if [[ `date +%s` > $end_time ]]; then
330
+ break
331
+ else
332
+ sleep 1
333
+ fi
334
+ done
335
+
336
+ if [[ $success ]]; then
337
+ echo "> container $name up: $container_id"
338
+ else
339
+ >&2 echo "container $name did not start up after $TIMEOUT seconds"
340
+ exit 1
341
+ fi
342
+ }
343
+
344
+ one_off_container() {
345
+ typeset port_args=$(docker_port_args 1)
346
+ typeset docker_cmd="/usr/bin/env docker run $port_args $DOCKER_RUN_ARGS $DOCKER_IMAGE"
347
+ if [[ $CONTAINER_NAME_PREFIX != "" ]]; then
348
+ docker_cmd="$docker_cmd --name $CONTAINER_NAME_PREFIX"
349
+ fi
350
+ if [[ $CONTAINER_BASE_HOST_NAME != "" ]]; then
351
+ docker_cmd="$docker_cmd --hostname $CONTAINER_BASE_HOST_NAME"
352
+ fi
353
+ if [[ $DOCKER_RUN_COMMAND != "" ]]; then
354
+ docker_cmd="$docker_cmd $DOCKER_RUN_COMMAND"
355
+ fi
356
+ exec $docker_cmd
357
+ }
358
+
359
+ ###
360
+ ### Parse command line options
361
+ ###
362
+
363
+ DOCKER_IMAGE=
364
+ CONTAINER_NAME_PREFIX=
365
+ CONTAINER_COUNT=1
366
+ PORT_MAPPING=()
367
+ DOCKER_RUN_COMMAND=
368
+ HEALTHCHECK=
369
+ TIMEOUT=120
370
+ CMD_OUT=/dev/null
371
+ DOCKER_RUN_ARGS=
372
+ FORCE_RESTART=
373
+ ONE_OFF_CONTAINER=
374
+
375
+ read_arguments $@
376
+
377
+ if [[ $ONE_OFF_CONTAINER == "1" ]]; then
378
+ one_off_container
379
+ fi
380
+
381
+ if [[ $CONTAINER_NAME_PREFIX == "" ]]; then
382
+ usage
383
+ exit 1
384
+ fi
385
+
386
+ if [[ $CONTAINER_COUNT > 0 ]]; then
387
+ if [[ $DOCKER_IMAGE == "" ]]; then
388
+ usage
389
+ exit 1
390
+ else
391
+ typeset image_id=$(/usr/bin/env docker inspect --type=image --format {{.Config.Image}} $DOCKER_IMAGE)
392
+ if [[ $image_id == "" ]]; then
393
+ >&2 echo "Could not find image $DOCKER_IMAGE"
394
+ exit 1
395
+ else
396
+ echo "> using image $image_id"
397
+ fi
398
+ fi
399
+ fi
400
+
401
+ if [[ $CONTAINER_BASE_HOST_NAME == "" ]]; then
402
+ CONTAINER_BASE_HOST_NAME=$(hostname)
403
+ fi
404
+
405
+ shutdown_excess_containers
406
+
407
+ if [[ $CONTAINER_COUNT > 0 ]]; then
408
+ # Cleanup docker environment so there are no surprises
409
+ /usr/bin/env docker container prune -f > $CMD_OUT
410
+ for i in $(seq 1 $CONTAINER_COUNT); do
411
+ typeset image_id=$(container_image_id $i)
412
+ if [[ $image_id != "" && $image_id == $DOCKER_IMAGE && $FORCE_RESTART == "" ]]; then
413
+ echo "> container ${CONTAINER_NAME_PREFIX}.${i} already running $DOCKER_IMAGE"
414
+ else
415
+ stop_container $i
416
+ start_container $i
417
+ fi
418
+ done
419
+ fi
420
+
421
+ /usr/bin/env docker ps | grep -F " $CONTAINER_NAME_PREFIX." || true
422
+
423
+ exit 0
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/capistrano/docker_cluster/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "capistrano-docker_cluster"
7
+ spec.version = Capistrano::DockerCluster::VERSION
8
+ spec.authors = ["Brian Durand"]
9
+ spec.email = ["bbdurand@gmail.com"]
10
+
11
+ spec.summary = %q{Use capistrano to deploy docker based applications.}
12
+ spec.homepage = "https://github.com/bdurand/capistrano-docker_cluster"
13
+ spec.license = "MIT"
14
+
15
+ # Specify which files should be added to the gem when it is released.
16
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
17
+ ignore_files = %w(
18
+ .gitignore
19
+ .travis.yml
20
+ Appraisals
21
+ Gemfile
22
+ Gemfile.lock
23
+ Rakefile
24
+ gemfiles/
25
+ spec/
26
+ )
27
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
28
+ `git ls-files -z`.split("\x0").reject{ |f| ignore_files.any?{ |path| f.start_with?(path) } }
29
+ end
30
+ spec.bindir = "bin"
31
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ spec.required_ruby_version = '>= 2.2.2'
35
+
36
+ spec.add_dependency "capistrano", "~> 3.7"
37
+ spec.add_dependency "capistrano-scm-none", "~> 0.1"
38
+ spec.add_development_dependency "rake"
39
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capistrano/scm/none'
4
+
5
+ require_relative "docker_cluster/version"
6
+ require_relative "docker_cluster/scripts"
7
+
8
+ load File.expand_path("tasks/docker_cluster.rake", __dir__)
9
+
10
+ install_plugin Capistrano::Scm::None::Plugin
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Capistrano
6
+ module DockerCluster
7
+ class Scripts
8
+ def initialize(context)
9
+ @context = context
10
+ end
11
+
12
+ # Build a custom command line start script with the configuration arguments for
13
+ # each docker application on the host. This allows each app to be started with
14
+ # the predefined configuration by calling `bin/start app`.
15
+ def start_script(host)
16
+ apps = Array(fetch_for_host(host, :docker_apps))
17
+ cmd = "exec bin/docker-cluster"
18
+ image_id = fetch(:docker_image_id)
19
+ prefix = fetch(:docker_prefix)
20
+
21
+ cases = []
22
+ apps.each do |app|
23
+ args = app_host_args(app, host)
24
+ cases << " '#{app}')\n #{cmd} #{args.join(' ')} --name '#{prefix}#{app}' --image '#{image_id}' \"$@\"\n ;;"
25
+ end
26
+
27
+ <<~BASH
28
+ #!/usr/bin/env bash
29
+
30
+ # Generated: #{Time.now.utc.iso8601}
31
+ # Docker image tag: #{fetch(:docker_repository)}/#{fetch(:docker_tag)}
32
+
33
+ set -o errexit
34
+
35
+ cd $(dirname $0)/..
36
+
37
+ typeset app=$1
38
+ shift
39
+
40
+ case $app in
41
+ #{cases.join("\n")}
42
+ *)
43
+ >&2 echo "Usage: $0 #{apps.join('|')}"
44
+ exit 1
45
+ esac
46
+ BASH
47
+ end
48
+
49
+ # Build a custom command line run script with the configuration arguments for
50
+ # each docker application on the host to run one off containers.
51
+ def run_script(host)
52
+ # For the run script, all configured apps are included, not just the deployed ones.
53
+ # This allows configuring, for example, a "console" app to open up a console in a container.
54
+ apps = Array(fetch_for_host(host, :docker_apps))
55
+ app_configs = fetch_for_host(host, :docker_app_configs)
56
+ apps.concat(app_configs.keys) if app_configs.is_a?(Hash)
57
+ app_args = fetch_for_host(host, :docker_app_args)
58
+ apps.concat(app_args.keys) if app_args.is_a?(Hash)
59
+ apps = apps.collect(&:to_s).uniq
60
+
61
+ cmd = "exec bin/docker-cluster"
62
+ image_id = fetch(:docker_image_id)
63
+
64
+ cases = []
65
+ apps.each do |app|
66
+ args = app_host_args(app, host)
67
+ cases << " '#{app}')\n #{cmd} #{args.join(' ')} --image '#{image_id}' --one-off \"$@\"\n ;;"
68
+ end
69
+
70
+ <<~BASH
71
+ #!/usr/bin/env bash
72
+
73
+ # Generated: #{Time.now.utc.iso8601}
74
+ # Docker image tag: #{fetch(:docker_repository)}/#{fetch(:docker_tag)}
75
+
76
+ set -o errexit
77
+
78
+ cd $(dirname $0)/..
79
+
80
+ typeset app=$1
81
+ shift
82
+
83
+ case $app in
84
+ #{cases.join("\n")}
85
+ *)
86
+ >&2 echo "Usage: $0 #{apps.join('|')}"
87
+ exit 1
88
+ esac
89
+ BASH
90
+ end
91
+
92
+ # Build a custom command line stop script for each docker application on the host.
93
+ def stop_script(host)
94
+ apps = Array(fetch_for_host(host, :docker_apps))
95
+ prefix = fetch(:docker_prefix)
96
+
97
+ cases = []
98
+ all = []
99
+ apps.each do |app|
100
+ cases << " '#{app}')\n exec bin/docker-cluster --name '#{prefix}#{app}' --count 0\n ;;"
101
+ end
102
+
103
+ <<~BASH
104
+ #!/usr/bin/env bash
105
+
106
+ # Generated: #{Time.now.utc.iso8601}
107
+ # Docker image tag: #{fetch(:docker_repository)}/#{fetch(:docker_tag)}
108
+
109
+ set -o errexit
110
+
111
+ cd $(dirname $0)/..
112
+
113
+ typeset app=$1
114
+
115
+ case $app in
116
+ #{cases.join("\n")}
117
+ *)
118
+ >&2 echo "Usage: $0 #{apps.join('|')}"
119
+ exit 1
120
+ esac
121
+ BASH
122
+ end
123
+
124
+ # Returns a list of all local configuration file paths that need to be uploaded to
125
+ # the host.
126
+ def docker_config_map(host)
127
+ configs = {}
128
+ Array(fetch(:docker_configs)).each do |path|
129
+ configs[File.basename(path)] = path
130
+ end
131
+
132
+ apps = Array(fetch_for_host(host, :docker_apps))
133
+
134
+ app_configs = app_configuration(fetch(:docker_app_configs, nil))
135
+ apps.each do |app|
136
+ Array(app_configs[app.to_s]).each do |path|
137
+ configs[File.basename(path)] = path
138
+ end
139
+ end
140
+
141
+ Array(host.properties.docker_configs).each do |path|
142
+ configs[File.basename(path)] = path
143
+ end
144
+
145
+ host_app_configs = app_configuration(host.properties.send(:docker_app_configs))
146
+ apps.each do |app|
147
+ Array(host_app_configs[app.to_s]).each do |path|
148
+ configs[File.basename(path)] = path
149
+ end
150
+ end
151
+
152
+ configs
153
+ end
154
+
155
+ # Fetch a host specific property. If a the value is not defined as host specific,
156
+ # then fallback to the globally defined property.
157
+ def fetch_for_host(host, property, default = nil)
158
+ host.properties.send(property) || fetch(property, default)
159
+ end
160
+
161
+ private
162
+
163
+ # Helper to fetch a property defined in the capistrano script.
164
+ def fetch(property, default = nil)
165
+ @context.fetch(property, default)
166
+ end
167
+
168
+ # Helper to normalize used to multiple configurations keyed by the app name
169
+ # to ensure that the keys are all strings.
170
+ def app_configuration(hash)
171
+ hash ||= {}
172
+ config = {}
173
+ hash.each do |key, value|
174
+ config[key.to_s] = value
175
+ end
176
+ config
177
+ end
178
+
179
+ # Translate a list of config file paths into command line arguments for the docker_clusther.sh command.
180
+ def config_args(config_files)
181
+ Array(config_files).collect{ |path| "--config 'config/#{File.basename(path)}'" }
182
+ end
183
+
184
+ def app_host_args(app, host)
185
+ config_args = config_args(fetch(:docker_configs, nil))
186
+ command_args = Array(fetch(:docker_args, nil))
187
+
188
+ host_config_args = config_args(host.properties.send(:docker_configs))
189
+ host_command_args = Array(host.properties.send(:"docker_args"))
190
+
191
+ app_configs = app_configuration(fetch(:docker_app_configs, {}))
192
+ app_args = app_configuration(fetch(:docker_app_args, {}))
193
+
194
+ host_app_configs = app_configuration(host.properties.send(:"docker_app_configs"))
195
+ host_app_args = app_configuration(host.properties.send(:"docker_app_args"))
196
+
197
+ app = app.to_s
198
+ app_config_args = config_args(app_configs[app])
199
+ app_command_args = Array(app_args[app])
200
+ host_app_config_args = config_args(host_app_configs[app])
201
+ host_app_command_args = Array(host_app_args[app])
202
+ args = config_args + command_args + app_config_args + app_command_args + host_config_args + host_command_args + host_app_config_args + host_app_command_args
203
+ args.uniq
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module DockerCluster
5
+ VERSION = "1.0.1"
6
+ end
7
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ set :docker_roles, [:docker]
4
+
5
+ def as_docker_user
6
+ user = fetch(:docker_user, nil)
7
+ if user
8
+ as(user) do
9
+ with(fetch(:docker_env, {})) do
10
+ yield
11
+ end
12
+ end
13
+ else
14
+ yield
15
+ end
16
+ end
17
+
18
+ namespace :deploy do
19
+ after :new_release_path, "docker:create_release"
20
+ after :updating, "docker:update"
21
+ after :published, "docker:restart"
22
+
23
+ task :upload do
24
+ end
25
+ end
26
+
27
+ namespace :docker do
28
+ desc "create the docker release directory and pull the image if necessary"
29
+ task :create_release do
30
+ on release_roles(fetch(:docker_roles)) do
31
+ execute :mkdir, "-p", release_path
32
+ end
33
+
34
+ if fetch(:docker_repository).include?("/")
35
+ invoke "docker:pull"
36
+ end
37
+ end
38
+
39
+ task :set_image_id => :build do
40
+ on release_roles(fetch(:docker_roles)).first do
41
+ docker_tag_url = "#{fetch(:docker_repository)}:#{fetch(:docker_tag)}"
42
+ as_docker_user do
43
+ image_id = capture(:docker, "image", "ls", "--no-trunc", "--format", "'{{.ID}}'", docker_tag_url)
44
+ set :docker_image_id, image_id[7, 12]
45
+ end
46
+ end
47
+ end
48
+
49
+ desc "Build and tag the docker image. This task does nothing by default, but can be implemented where needed."
50
+ task :build do
51
+ end
52
+
53
+ desc "Update the configuration and command line arguments for running a docker deployment."
54
+ task :update => :set_image_id do
55
+ invoke("docker:copy_configs")
56
+ invoke("docker:upload_commands")
57
+ end
58
+
59
+ desc "Prune the docker engine of all dangling images, containers, and volumes."
60
+ task :prune do
61
+ on release_roles(fetch(:docker_roles)) do |host|
62
+ as_docker_user do
63
+ execute :docker, "system", "prune", "--force"
64
+ end
65
+ end
66
+ end
67
+
68
+ desc "Pull a tag from a remote into the local docker engine. If the task docker:authenticate is defined, it will be invoked first."
69
+ task :pull do
70
+ docker_tag_url = "#{fetch(:docker_repository)}:#{fetch(:docker_tag)}"
71
+ if Rake::Task.task_defined?('docker:authenticate')
72
+ invoke "docker:authenticate"
73
+ end
74
+ on release_roles(fetch(:docker_roles)) do |host|
75
+ as_docker_user do
76
+ execute :docker, "pull", docker_tag_url
77
+ end
78
+ end
79
+ end
80
+
81
+ desc "Restart the docker containers (alias to docker:start)."
82
+ task :restart do
83
+ invoke("docker:start")
84
+ end
85
+
86
+ desc "Restart the docker containers."
87
+ task :start do
88
+ on release_roles(fetch(:docker_roles)) do |host|
89
+ within "#{fetch(:deploy_to)}/current" do
90
+ scripts = Capistrano::DockerCluster::Scripts.new(self)
91
+ Array(scripts.fetch_for_host(host, :docker_apps)).each do |app|
92
+ as_docker_user do
93
+ execute "bin/start", app
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ desc "Stop the docker containers."
101
+ task :stop do
102
+ on release_roles(fetch(:docker_roles)) do |host|
103
+ within "#{fetch(:deploy_to)}/current" do
104
+ scripts = Capistrano::DockerCluster::Scripts.new(self)
105
+ Array(scripts.fetch_for_host(host, :docker_apps)).each do |app|
106
+ as_docker_user do
107
+ execute "bin/stop", app
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ desc "Upload the commands to stop and start the application docker containers."
115
+ task :upload_commands do
116
+ on release_roles(fetch(:docker_roles)) do |host|
117
+ within fetch(:release_path) do
118
+ execute(:mkdir, "-p", "bin")
119
+ scripts = Capistrano::DockerCluster::Scripts.new(self)
120
+ docker_cluster_path = File.join(__dir__, "..", "..", "..", "bin", "docker-cluster")
121
+ upload! docker_cluster_path, "bin/docker-cluster"
122
+ upload! StringIO.new(scripts.start_script(host)), "bin/start"
123
+ upload! StringIO.new(scripts.stop_script(host)), "bin/stop"
124
+ upload! StringIO.new(scripts.run_script(host)), "bin/run"
125
+ execute :chmod, "a+x", "bin/*"
126
+ end
127
+ end
128
+ end
129
+
130
+ desc "Copy configuration files used to start the docker containers."
131
+ task :copy_configs do
132
+ on release_roles(fetch(:docker_roles)) do |host|
133
+ configs = Capistrano::DockerCluster::Scripts.new(self).docker_config_map(host)
134
+ unless configs.empty?
135
+ within fetch(:release_path) do
136
+ execute(:mkdir, "-p", "config")
137
+ configs.each do |name, local_path|
138
+ upload! local_path, "config/#{name}"
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: capistrano-docker_cluster
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Brian Durand
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-03-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: capistrano
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: capistrano-scm-none
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description:
56
+ email:
57
+ - bbdurand@gmail.com
58
+ executables:
59
+ - docker-cluster
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGE_LOG.md
64
+ - MIT_LICENSE.txt
65
+ - README.md
66
+ - bin/docker-cluster
67
+ - capistrano-docker_cluster.gemspec
68
+ - lib/capistrano/docker_cluster.rb
69
+ - lib/capistrano/docker_cluster/scripts.rb
70
+ - lib/capistrano/docker_cluster/version.rb
71
+ - lib/capistrano/tasks/docker_cluster.rake
72
+ homepage: https://github.com/bdurand/capistrano-docker_cluster
73
+ licenses:
74
+ - MIT
75
+ metadata: {}
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 2.2.2
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.0.3
92
+ signing_key:
93
+ specification_version: 4
94
+ summary: Use capistrano to deploy docker based applications.
95
+ test_files: []