├── .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 <public@bmitch.net> 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 | --------------------------------------------------------------------------------