diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dd33942 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +* +!docker/ +docker/* +!docker/run-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f06a0ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + pull_request: + +jobs: + syntax: + runs-on: ubuntu-latest + env: + ANSIBLE_ROLES_PATH: ${{ github.workspace }}/.ansible/roles + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ansible + run: | + python -m pip install --upgrade pip + python -m pip install ansible-core + ansible-galaxy collection install ansible.posix + mkdir -p "$ANSIBLE_ROLES_PATH" + ansible-galaxy role install -f -p "$ANSIBLE_ROLES_PATH" ansistrano.deploy + ln -sfn "$GITHUB_WORKSPACE" "$ANSIBLE_ROLES_PATH/local-ansistrano" + + - name: Run syntax check + run: | + printf 'localhost ansible_connection=local\n' > inventory + ansible-playbook -i inventory --syntax-check test/test.yml + + integration: + runs-on: ubuntu-latest + env: + ANSIBLE_ROLES_PATH: ${{ github.workspace }}/.ansible/roles + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ansible + run: | + python -m pip install --upgrade pip + python -m pip install ansible-core + ansible-galaxy collection install ansible.posix + mkdir -p "$ANSIBLE_ROLES_PATH" + ansible-galaxy role install -f -p "$ANSIBLE_ROLES_PATH" ansistrano.deploy + ln -sfn "$GITHUB_WORKSPACE" "$ANSIBLE_ROLES_PATH/local-ansistrano" + + - name: Run integration tests + run: | + printf 'localhost ansible_connection=local\n' > inventory + ansible-playbook -i inventory --connection=local --become -e update_cache=1 -v test/deploy.yml + ansible-playbook -i inventory --connection=local --become -e update_cache=1 -v test/test.yml diff --git a/.github/workflows/galaxy-import.yml b/.github/workflows/galaxy-import.yml new file mode 100644 index 0000000..11938d2 --- /dev/null +++ b/.github/workflows/galaxy-import.yml @@ -0,0 +1,30 @@ +name: Refresh Ansible Galaxy + +on: + push: + tags: + - "*" + workflow_dispatch: + +jobs: + import: + name: Import role on tag + runs-on: ubuntu-latest + + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ansible + run: python -m pip install --upgrade pip ansible-core + + - name: Trigger Ansible Galaxy import + env: + GALAXY_API_KEY: ${{ secrets.GALAXY_API_KEY }} + run: > + ansible-galaxy role import + --api-key "$GALAXY_API_KEY" + ansistrano + rollback diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index cdda8a7..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ ---- -dist: focal -language: python -python: - - "3.9" - -matrix: - include: - - env: ANSIBLE_VERSION=2.10.7 - - env: ANSIBLE_VERSION=3.4.0 - - env: ANSIBLE_VERSION=4.10.0 - - env: ANSIBLE_VERSION=5.10.0 - - env: ANSIBLE_VERSION=6.0.0 - -before_install: - - sudo apt-get -y install software-properties-common - - sudo -H pip install --no-cache-dir ansible==$ANSIBLE_VERSION - - ansible --version - # We download the latest deploy stable tag - - sudo ansible-galaxy install -c ansistrano.deploy - -script: - - echo localhost > inventory - - ansible-playbook -i inventory test/test.yml --syntax-check - - ansible-playbook -i inventory --connection=local --become -v test/deploy.yml - - ansible-playbook -i inventory --connection=local --become -v test/deploy.yml - - ansible-playbook -i inventory --connection=local --become -v test/test.yml - -notifications: - webhooks: https://galaxy.ansible.com/api/v1/notifications/ diff --git a/README.md b/README.md index c23217c..a97cb31 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,16 @@ +[![CI](https://github.com/ansistrano/rollback/actions/workflows/ci.yml/badge.svg)](https://github.com/ansistrano/rollback/actions/workflows/ci.yml) + Further details on Ansistrano ----------------------------- If you want to know more about Ansistrano, please check the [complete Ansistrano docs](https://github.com/ansistrano/deploy/blob/master/README.md) + +Testing +------- + +The role is tested in GitHub Actions on `ubuntu-latest` with a modern `ansible-core` setup. For local development, use the Docker runner: + +```bash +./scripts/test-in-docker.sh syntax +./scripts/test-in-docker.sh integration +``` diff --git a/docker/run-tests.sh b/docker/run-tests.sh new file mode 100755 index 0000000..53c2829 --- /dev/null +++ b/docker/run-tests.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-integration}" +shift || true + +repo_dir="/workspace/rollback" +inventory_file="/tmp/ansistrano-inventory" +roles_path="/etc/ansible/roles" + +mkdir -p "${roles_path}" +ln -sfn "${repo_dir}" "${roles_path}/local-ansistrano" +printf 'localhost ansible_connection=local\n' > "${inventory_file}" + +ANSIBLE_ROLES_PATH="${roles_path}" ansible-galaxy role install -f -p "${roles_path}" ansistrano.deploy + +cd "${repo_dir}/test" + +case "${mode}" in + syntax) + exec env ANSIBLE_ROLES_PATH="${roles_path}" ansible-playbook -i "${inventory_file}" --syntax-check test.yml "$@" + ;; + integration) + env ANSIBLE_ROLES_PATH="${roles_path}" ansible-playbook -i "${inventory_file}" --connection=local --become -e update_cache=1 -v deploy.yml "$@" + exec env ANSIBLE_ROLES_PATH="${roles_path}" ansible-playbook -i "${inventory_file}" --connection=local --become -e update_cache=1 -v test.yml "$@" + ;; + *) + echo "Usage: run-ansistrano-tests [syntax|integration] [extra ansible-playbook args...]" >&2 + exit 2 + ;; +esac diff --git a/docker/test-runner.Dockerfile b/docker/test-runner.Dockerfile new file mode 100644 index 0000000..677a4d4 --- /dev/null +++ b/docker/test-runner.Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + rsync \ + sudo \ + && python -m pip install --upgrade pip \ + && python -m pip install --no-cache-dir ansible-core \ + && ansible-galaxy collection install ansible.posix \ + && rm -rf /var/lib/apt/lists/* + +COPY --chmod=755 docker/run-tests.sh /usr/local/bin/run-ansistrano-tests + +WORKDIR /workspace/rollback +ENTRYPOINT ["run-ansistrano-tests"] diff --git a/scripts/test-in-docker.sh b/scripts/test-in-docker.sh new file mode 100755 index 0000000..7e747a1 --- /dev/null +++ b/scripts/test-in-docker.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_dir="$(cd "${script_dir}/.." && pwd)" + +mode="${1:-integration}" +shift || true + +image_name="${ANSISTRANO_TEST_IMAGE:-ansistrano-rollback-test}" + +docker build -f "${repo_dir}/docker/test-runner.Dockerfile" -t "${image_name}" "${repo_dir}" + +docker_run_args=( + run + --rm + -e ANSIBLE_VERSION=docker + -v "${repo_dir}:/workspace/rollback" +) + +if [ -t 0 ] && [ -t 1 ]; then + docker_run_args+=(-it) +fi + +docker "${docker_run_args[@]}" "${image_name}" "${mode}" "$@" diff --git a/tasks/cleanup.yml b/tasks/cleanup.yml index 1f6c5b3..15aeb4b 100644 --- a/tasks/cleanup.yml +++ b/tasks/cleanup.yml @@ -2,5 +2,5 @@ - name: ANSISTRANO | Remove rolled back version file: state: absent - path: "{{ ansistrano_releases_path.stdout }}/{{ ansistrano_current_release_version.stdout }}" + path: "{{ ansistrano_releases_path }}/{{ ansistrano_current_release_version }}" when: ansistrano_remove_rolled_back diff --git a/tasks/setup.yml b/tasks/setup.yml index d51b601..843205a 100644 --- a/tasks/setup.yml +++ b/tasks/setup.yml @@ -1,24 +1,30 @@ --- - name: ANSISTRANO | Get releases path - command: echo "{{ ansistrano_deploy_to }}/{{ ansistrano_version_dir }}" - check_mode: no - register: ansistrano_releases_path + set_fact: + ansistrano_releases_path: "{{ ansistrano_deploy_to }}/{{ ansistrano_version_dir }}" -- name: ANSISTRANO | Get number of releases - shell: echo `ls -1t {{ ansistrano_releases_path.stdout }} | wc -l` - register: ansistrano_versions_count +- name: ANSISTRANO | Gather releases + find: + paths: "{{ ansistrano_releases_path }}" + file_type: directory + recurse: false + register: ansistrano_releases - name: ANSISTRANO | Check if there is more than one release fail: msg: "Could not roll back the code because there is no prior release" - when: ansistrano_versions_count.stdout|int <= 1 + when: ansistrano_releases.matched | int <= 1 + +- name: ANSISTRANO | Sort releases by mtime + set_fact: + ansistrano_sorted_releases: "{{ ansistrano_releases.files | sort(attribute='mtime', reverse=true) }}" - name: ANSISTRANO | Get current release version - shell: echo `ls -1t {{ ansistrano_releases_path.stdout }} | head -n 1` - register: ansistrano_current_release_version + set_fact: + ansistrano_current_release_version: "{{ ansistrano_sorted_releases[0].path | basename }}" - stat: - path: "{{ ansistrano_releases_path.stdout }}/{{ ansistrano_rollback_to_release }}" + path: "{{ ansistrano_releases_path }}/{{ ansistrano_rollback_to_release }}" register: stat_rollback_release_version when: ansistrano_rollback_to_release != "" @@ -28,14 +34,13 @@ when: ansistrano_rollback_to_release != "" and (stat_rollback_release_version.stat.exists is not defined or stat_rollback_release_version.stat.isdir == False) - name: ANSISTRANO | Get previous releases version - shell: echo `ls -1t {{ ansistrano_releases_path.stdout }} | head -n 2 | tail -n 1` - register: ansistrano_previous_release_version + set_fact: + ansistrano_previous_release_version: "{{ ansistrano_sorted_releases[1].path | basename }}" - name: ANSISTRANO | Get rollback release version set_fact: - ansistrano_rollback_release_version: "{{ ansistrano_rollback_to_release if ansistrano_rollback_to_release != '' else ansistrano_previous_release_version.stdout }}" + ansistrano_rollback_release_version: "{{ ansistrano_rollback_to_release if ansistrano_rollback_to_release != '' else ansistrano_previous_release_version }}" - name: ANSISTRANO | Get release path - command: echo "{{ ansistrano_releases_path.stdout }}/{{ ansistrano_rollback_release_version }}" - check_mode: no - register: ansistrano_release_path + set_fact: + ansistrano_release_path: "{{ ansistrano_releases_path }}/{{ ansistrano_rollback_release_version }}" diff --git a/test/deploy.yml b/test/deploy.yml index da8b119..ad92268 100644 --- a/test/deploy.yml +++ b/test/deploy.yml @@ -2,5 +2,48 @@ hosts: all vars: ansistrano_deploy_to: "/tmp/my-app.com" + ansistrano_release_version: "20000101000000Z" + roles: + - { role: ansistrano.deploy } + +- name: Do a second deploy to create a rollback target + hosts: all + vars: + ansistrano_deploy_to: "/tmp/my-app.com" + ansistrano_release_version: "20000101000001Z" + roles: + - { role: ansistrano.deploy } + +- name: Do multiple deploys to support explicit rollback tests + hosts: all + vars: + ansistrano_deploy_to: "/tmp/my-other-app.com" + tasks: + - name: Clear previous deploy target + file: + path: "{{ ansistrano_deploy_to }}" + state: absent + +- name: Deploy first release for explicit rollback tests + hosts: all + vars: + ansistrano_deploy_to: "/tmp/my-other-app.com" + ansistrano_release_version: "20000101000000Z" + roles: + - { role: ansistrano.deploy } + +- name: Deploy second release for explicit rollback tests + hosts: all + vars: + ansistrano_deploy_to: "/tmp/my-other-app.com" + ansistrano_release_version: "20000101000001Z" + roles: + - { role: ansistrano.deploy } + +- name: Deploy third release for explicit rollback tests + hosts: all + vars: + ansistrano_deploy_to: "/tmp/my-other-app.com" + ansistrano_release_version: "20000101000002Z" roles: - { role: ansistrano.deploy } diff --git a/test/test.yml b/test/test.yml index 4b5d2b9..97925ad 100644 --- a/test/test.yml +++ b/test/test.yml @@ -5,3 +5,58 @@ ansistrano_deploy_to: "/tmp/my-app.com" roles: - { role: local-ansistrano } + post_tasks: + - name: Assert current points to the previous release + stat: + path: "{{ ansistrano_deploy_to }}/current" + register: current_release + + - name: Assert previous release remains available + stat: + path: "{{ ansistrano_deploy_to }}/releases/20000101000000Z" + register: previous_release + + - name: Assert rolled back release was removed + stat: + path: "{{ ansistrano_deploy_to }}/releases/20000101000001Z" + register: removed_release + + - name: Assert default rollback result + assert: + that: + - current_release.stat.islnk is defined and current_release.stat.islnk + - current_release.stat.lnk_target == "./releases/20000101000000Z" + - previous_release.stat.exists is defined and previous_release.stat.exists + - removed_release.stat.exists is defined and not removed_release.stat.exists + +- name: Rolling back to a specific release without deleting the current one + hosts: all + vars: + ansistrano_deploy_to: "/tmp/my-other-app.com" + ansistrano_remove_rolled_back: false + ansistrano_rollback_to_release: "20000101000000Z" + roles: + - { role: local-ansistrano } + post_tasks: + - name: Assert current points to the requested release + stat: + path: "{{ ansistrano_deploy_to }}/current" + register: current_release + + - name: Assert requested rollback release still exists + stat: + path: "{{ ansistrano_deploy_to }}/releases/20000101000000Z" + register: requested_release + + - name: Assert rolled back release is still present + stat: + path: "{{ ansistrano_deploy_to }}/releases/20000101000002Z" + register: kept_release + + - name: Assert explicit rollback result + assert: + that: + - current_release.stat.islnk is defined and current_release.stat.islnk + - current_release.stat.lnk_target == "./releases/20000101000000Z" + - requested_release.stat.exists is defined and requested_release.stat.exists + - kept_release.stat.exists is defined and kept_release.stat.exists