├── .dockerignore
├── .github
└── workflows
│ ├── ISSUE_TEMPLATE
│ └── issue.md
│ └── docker.yml
├── Dockerfile
├── LICENSE
├── README.md
├── dind-docker-compose.yml
├── docker-stack-wait.sh
└── example-docker-compose.yml
/.dockerignore:
--------------------------------------------------------------------------------
1 | *
2 | !docker-stack-wait.sh
3 |
--------------------------------------------------------------------------------
/.github/workflows/ISSUE_TEMPLATE/issue.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Issue
3 | about: Report a bug or issue
4 | title: '[Issue]
'
5 | labels: bug
6 | assignees:
7 | - sudo-bmitch
8 |
9 | ---
10 |
11 | ### Describe the problem
12 |
13 | ### Current behavior
14 |
15 | ### Desired behavior
16 |
17 | ### Steps to reproduce
18 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 | - 'feature/**'
8 | tags:
9 | - 'v*.*.*'
10 | # schedule:
11 | # - cron: '0 06 * * 1'
12 |
13 | jobs:
14 |
15 | docker:
16 | name: Docker
17 | runs-on: ubuntu-latest
18 | steps:
19 | - name: Check out code
20 | uses: actions/checkout@v4
21 |
22 | - name: Prepare
23 | id: prep
24 | run: |
25 | HUB_IMAGE=sudobmitch/docker-stack-wait
26 | GHCR_IMAGE=ghcr.io/sudo-bmitch/docker-stack-wait
27 | VERSION=noop
28 | if [ "${{ github.event_name }}" = "schedule" ]; then
29 | VERSION=edge
30 | elif [[ $GITHUB_REF == refs/tags/* ]]; then
31 | VERSION=${GITHUB_REF#refs/tags/}
32 | elif [[ $GITHUB_REF == refs/heads/* ]]; then
33 | VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's#/+#-#g')
34 | if [ "${{ github.event.repository.default_branch }}" = "$VERSION" ]; then
35 | VERSION=edge
36 | fi
37 | elif [[ $GITHUB_REF == refs/pull/* ]]; then
38 | VERSION=pr-${{ github.event.number }}
39 | fi
40 | TAGS="${HUB_IMAGE}:${VERSION},${GHCR_IMAGE}:${VERSION}"
41 | if [[ $VERSION =~ ^v[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
42 | MINOR=${VERSION%.*}
43 | MAJOR=${MINOR%.*}
44 | TAGS="${TAGS},${HUB_IMAGE}:${MINOR},${HUB_IMAGE}:${MAJOR},${HUB_IMAGE}:latest,${GHCR_IMAGE}:${MINOR},${GHCR_IMAGE}:${MAJOR},${GHCR_IMAGE}:latest"
45 | fi
46 | VCS_SEC="$(git log -1 --format=%ct)"
47 | VCS_DATE="$(date -d "@${VCS_SEC}" +%Y-%m-%dT%H:%M:%SZ --utc)"
48 | echo "version=${VERSION}" >>$GITHUB_OUTPUT
49 | echo "tags=${TAGS}" >>$GITHUB_OUTPUT
50 | echo "created=${VCS_DATE}" >>$GITHUB_OUTPUT
51 |
52 | - name: Set up Docker Buildx
53 | uses: docker/setup-buildx-action@v3
54 |
55 | - name: Login to DockerHub
56 | if: github.repository_owner == 'sudo-bmitch'
57 | uses: docker/login-action@v3
58 | with:
59 | username: ${{ secrets.DOCKERHUB_USERNAME }}
60 | password: ${{ secrets.DOCKERHUB_TOKEN }}
61 |
62 | - name: Login to GHCR
63 | if: github.repository_owner == 'sudo-bmitch'
64 | uses: docker/login-action@v3
65 | with:
66 | registry: ghcr.io
67 | username: ${{ secrets.GHCR_USERNAME }}
68 | password: ${{ secrets.GHCR_TOKEN }}
69 |
70 | - name: Build and push
71 | uses: docker/build-push-action@v6
72 | with:
73 | context: .
74 | file: ./Dockerfile
75 | platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
76 | push: ${{ github.event_name != 'pull_request' && github.repository_owner == 'sudo-bmitch' }}
77 | tags: ${{ steps.prep.outputs.tags }}
78 | labels: |
79 | org.opencontainers.image.created=${{ steps.prep.outputs.created }}
80 | org.opencontainers.image.source=${{ github.repositoryUrl }}
81 | org.opencontainers.image.version=${{ steps.prep.outputs.version }}
82 | org.opencontainers.image.revision=${{ github.sha }}
83 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG DOCKER_VER=stable
2 | FROM docker:${DOCKER_VER}
3 |
4 | COPY docker-stack-wait.sh /
5 |
6 | ENTRYPOINT [ "/docker-stack-wait.sh" ]
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2018 Brandon Mitchell
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Docker Stack Wait
2 |
3 | Waits for a docker stack deploy to complete.
4 |
5 | ## Archive Notice
6 |
7 | This project has been archived and is no longer being maintained.
8 | Docker has added the `--detach` CLI flag which allows similar functionality to be implemented with:
9 |
10 | ```shell
11 | docker stack deploy --detach=false -c docker-compose.yaml $stack_name
12 | ```
13 |
14 | ## CLI Usage
15 |
16 | Example Usage:
17 |
18 | `docker-stack-wait.sh $stack_name`
19 |
20 | Help output:
21 |
22 | ```bash
23 | $ ./docker-stack-wait.sh -h
24 | docker-stack-wait.sh [opts] stack_name
25 | -f filter: only wait for services matching filter, may be passed multiple
26 | times, see docker stack services for the filter syntax
27 | -h: this help message
28 | -l flags: Print logs of relevant services at end.
29 | Flags are passed directly to the end of 'docker service logs'.
30 | Example usage: -l '--tail 20' or -l '--since 20m'
31 | -n name: only wait for specific service names, overrides any filters,
32 | may be passed multiple times, do not include the stack name prefix
33 | -r: treat a rollback as successful
34 | -s sec: frequency to poll service state (default 5 sec)
35 | -t sec: timeout to stop waiting (default 3600 sec)
36 | ```
37 |
38 | ## Usage as container
39 |
40 | An image is available at:
41 |
42 | - Docker Hub: `sudobmitch/docker-stack-wait`
43 | - GHCR: `ghcr.io/sudo-bmitch/docker-stack-wait`
44 |
45 | To use this image, you will need to mount the docker socket:
46 |
47 | ```bash
48 | $ docker run --rm -it \
49 | -v /var/run/docker.sock:/var/run/docker.sock \
50 | sudobmitch/docker-stack-wait $stack_name
51 | ```
52 |
53 | or with an alias
54 |
55 | ```bash
56 | $ alias docker-stack-wait='docker run --rm -it \
57 | -v /var/run/docker.sock:/var/run/docker.sock \
58 | sudobmitch/docker-stack-wait'
59 | ```
60 |
61 | ## Development
62 |
63 | To test changes to the script easily, you can use the example `example-docker-compose.yml` file with:
64 |
65 | ```bash
66 | docker-compose -f dind-docker-compose.yml up
67 | docker-compose -f dind-docker-compose.yml exec dind sh
68 | docker node ls || docker swarm init
69 | docker stack deploy --compose-file work/example-docker-compose.yml the_stack
70 | ./work/docker-stack-wait.sh the_stack
71 | ```
72 |
73 | ## Filter Examples
74 |
75 | The `-n` and `-f` options allow to select a subset of the services in a stack.
76 | With the following compose yml file:
77 |
78 | ```yaml
79 | version: '3.7'
80 |
81 | services:
82 | normal:
83 | image: busybox
84 | command: /bin/sh -c ":>/healthy; tail -f /dev/null"
85 | deploy:
86 | labels:
87 | deploy.wait: "true"
88 | deploy.quick: "true"
89 | healthcheck:
90 | test: /bin/sh -c "[ -f /healthy ] && exit 0 || exit 1"
91 | interval: 15s
92 | start_period: 60s
93 | retries: 3
94 |
95 | slow:
96 | image: busybox
97 | command: /bin/sh -c "sleep 50; :>/healthy; tail -f /dev/null"
98 | deploy:
99 | labels:
100 | deploy.wait: "true"
101 | healthcheck:
102 | test: /bin/sh -c "[ -f /healthy ] && exit 0 || exit 1"
103 | interval: 15s
104 | start_period: 60s
105 | retries: 3
106 |
107 | tooslow:
108 | image: busybox
109 | command: /bin/sh -c "sleep 300; :>/healthy; tail -f /dev/null"
110 | deploy:
111 | labels:
112 | deploy.wait: "false"
113 | healthcheck:
114 | test: /bin/sh -c "[ -f /healthy ] && exit 0 || exit 1"
115 | interval: 15s
116 | start_period: 60s
117 | retries: 3
118 | ```
119 |
120 | We can wait for only the first two services using labels:
121 |
122 | ```bash
123 | docker-stack-wait.sh -f label=deploy.wait=true waittest
124 | ```
125 |
126 | Or by waiting on individual service names:
127 |
128 | ```bash
129 | docker-stack-wait.sh -n normal -n slow waittest
130 | ```
131 |
132 | If you deploy a stack using multiple compose files, you can wait for the
133 | services in a single compose file using the following example that uses
134 | `docker-compose` to generate a list of services from one file:
135 |
136 | ```bash
137 | wait_args=""
138 | for arg in $(docker-compose -f docker-compose.yml config --services 2>/dev/null); do
139 | wait_args="${wait_args:+${wait_args} }-n $arg"
140 | done
141 | docker-stack-wait.sh $wait_args waittest
142 | ```
143 |
--------------------------------------------------------------------------------
/dind-docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | dind:
4 | image: docker:dind
5 | container_name: dind
6 | privileged: true
7 | volumes:
8 | - .:/work
9 | expose:
10 | - 2375
11 | environment:
12 | - DOCKER_TLS_CERTDIR=
13 |
14 |
--------------------------------------------------------------------------------
/docker-stack-wait.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # By: Brandon Mitchell
4 | # License: MIT
5 | # Source repo: https://github.com/sudo-bmitch/docker-stack-wait
6 |
7 | set -e
8 | trap "{ exit 1; }" TERM INT
9 | opt_h=0
10 | opt_l=""
11 | opt_r=0
12 | opt_s=5
13 | opt_t=3600
14 | start_epoc=$(date +%s)
15 | cmd_min_timeout=15
16 |
17 | usage() {
18 | echo "$(basename $0) [opts] stack_name"
19 | echo " -f filter: only wait for services matching filter, may be passed multiple"
20 | echo " times, see docker stack services for the filter syntax"
21 | echo " -h: this help message"
22 | echo " -l flags: Print logs of relevant services at end."
23 | echo " Flags are passed directly to the end of 'docker service logs'."
24 | echo " Example usage: -l '--tail 20' or -l '--since 20m'"
25 | echo " -n name: only wait for specific service names, overrides any filters,"
26 | echo " may be passed multiple times, do not include the stack name prefix"
27 | echo " -r: treat a rollback as successful"
28 | echo " -s sec: frequency to poll service state (default $opt_s sec)"
29 | echo " -t sec: timeout to stop waiting (default $opt_t sec)"
30 | [ "$opt_h" = "1" ] && exit 0 || exit 1
31 | }
32 | check_timeout() {
33 | # timeout when a timeout is defined and we will exceed the timeout after the
34 | # next sleep completes
35 | if [ "$opt_t" -gt 0 ]; then
36 | cur_epoc=$(date +%s)
37 | cutoff_epoc=$(expr ${start_epoc} + $opt_t - $opt_s)
38 | if [ "$cur_epoc" -gt "$cutoff_epoc" ]; then
39 | echo "Error: Timeout exceeded"
40 | print_service_logs
41 | exit 1
42 | fi
43 | fi
44 | }
45 | cmd_with_timeout() {
46 | # run a command that will not exceed the timeout
47 | # there is a minimum time all commands are given
48 | if [ "$opt_t" -gt 0 ]; then
49 | cur_epoc=$(date +%s)
50 | remain_timeout=$(expr ${start_epoc} + ${opt_t} - ${cur_epoc})
51 | if [ "${remain_timeout}" -lt "${cmd_min_timeout}" ]; then
52 | remain_timeout=${cmd_min_timeout}
53 | fi
54 | timeout ${remain_timeout} "$@"
55 | else
56 | "$@"
57 | fi
58 | }
59 | get_service_ids() {
60 | if [ -n "$opt_n" ]; then
61 | service_list=""
62 | for name in $opt_n; do
63 | service_list="${service_list:+${service_list} }${stack_name}_${name}"
64 | done
65 | docker service inspect --format '{{.ID}}' ${service_list}
66 | else
67 | docker stack services ${opt_f} -q "${stack_name}"
68 | fi
69 | }
70 | service_state() {
71 | # output the state when it changes from the last state for the service
72 | service=$1
73 | # strip any invalid chars from service name for caching state
74 | service_safe=$(echo "$service" | sed 's/[^A-Za-z0-9_]/_/g')
75 | state=$2
76 | if eval [ \"\$cache_${service_safe}\" != \"\$state\" ]; then
77 | echo "Service $service state: $state"
78 | eval cache_${service_safe}=\"\$state\"
79 | fi
80 | }
81 | print_service_logs() {
82 | if [ "$opt_l" != "" ]; then
83 | service_ids=$(get_service_ids)
84 | for service_id in ${service_ids}; do
85 | cmd_with_timeout docker service logs $opt_l "$service_id"
86 | done
87 | fi
88 | }
89 |
90 | while getopts 'f:hl:n:p:rs:t:' opt; do
91 | case $opt in
92 | f) opt_f="${opt_f:+${opt_f} }-f $OPTARG";;
93 | h) opt_h=1;;
94 | l) opt_l="$OPTARG";;
95 | n) opt_n="${opt_n:+${opt_n} } $OPTARG";;
96 | p) opt_l="--tail $OPTARG";; # -p was deprecated in favor of -l
97 | r) opt_r=1;;
98 | s) opt_s="$OPTARG";;
99 | t) opt_t="$OPTARG";;
100 | esac
101 | done
102 | shift $(expr $OPTIND - 1)
103 |
104 | if [ $# -ne 1 -o "$opt_h" = "1" -o "$opt_s" -le "0" ]; then
105 | usage
106 | fi
107 |
108 | stack_name=$1
109 |
110 | # 0 = running, 1 = success, 2 = error
111 | stack_done=0
112 | while [ "$stack_done" != "1" ]; do
113 | stack_done=1
114 | # run get_service_ids outside of the for loop to catch errors
115 | service_ids=$(get_service_ids)
116 | if [ -z "${service_ids}" ]; then
117 | echo "Error: no services found" >&2
118 | exit 1
119 | fi
120 | for service_id in ${service_ids}; do
121 | service_done=1
122 | service=$(docker service inspect --format '{{.Spec.Name}}' "$service_id")
123 |
124 | # hardcode a "deployed" state when UpdateStatus is not defined
125 | state=$(docker service inspect -f '{{if .UpdateStatus}}{{.UpdateStatus.State}}{{else}}deployed{{end}}' "$service_id")
126 |
127 | # check for failed update states
128 | case "$state" in
129 | paused|rollback_paused)
130 | service_done=2
131 | ;;
132 | rollback_*)
133 | if [ "$opt_r" = "0" ]; then
134 | service_done=2
135 | fi
136 | ;;
137 | esac
138 |
139 | # identify/report current state
140 | if [ "$service_done" != "2" ]; then
141 | replicas=$(docker service ls --format '{{.Replicas}}' --filter "id=$service_id" | cut -d' ' -f1)
142 | current=$(echo "$replicas" | cut -d/ -f1)
143 | target=$(echo "$replicas" | cut -d/ -f2)
144 | if [ "$current" != "$target" ]; then
145 | # actively replicating service
146 | service_done=0
147 | state="replicating $replicas"
148 | fi
149 | fi
150 | service_state "$service" "$state"
151 |
152 | # check for states that indicate an update is done
153 | if [ "$service_done" = "1" ]; then
154 | case "$state" in
155 | deployed|completed|rollback_completed)
156 | service_done=1
157 | ;;
158 | *)
159 | # any other state is unknown, not necessarily finished
160 | service_done=0
161 | ;;
162 | esac
163 | fi
164 |
165 | # update stack done state
166 | if [ "$service_done" = "2" ]; then
167 | # error condition
168 | stack_done=2
169 | elif [ "$service_done" = "0" -a "$stack_done" = "1" ]; then
170 | # only go to an updating state if not in an error state
171 | stack_done=0
172 | fi
173 | done
174 | if [ "$stack_done" = "2" ]; then
175 | echo "Error: This deployment will not complete"
176 | print_service_logs
177 | exit 1
178 | fi
179 | if [ "$stack_done" != "1" ]; then
180 | check_timeout
181 | sleep "${opt_s}"
182 | fi
183 | done
184 |
185 | print_service_logs
186 |
--------------------------------------------------------------------------------
/example-docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | example1:
4 | image: alpine
5 | command: sh -c "echo example1 && tail -f /dev/null"
6 | deploy:
7 | labels:
8 | type: service
9 | example2:
10 | image: alpine
11 | command: sh -c "echo example2 && tail -f /dev/null"
12 | deploy:
13 | labels:
14 | type: service
15 | example3:
16 | image: alpine
17 | command: sh -c "echo example3 starting && sleep 1 && echo example3 started && tail -f /dev/null"
18 | deploy:
19 | labels:
20 | type: service
21 | expected-exit:
22 | image: alpine
23 | command: sh -c "sleep 1; exit 0"
24 | deploy:
25 | labels:
26 | type: job
27 | restart_policy:
28 | condition: on-failure
29 |
--------------------------------------------------------------------------------