From 807808d7460b062e7af88b566513993f6ec23952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Mon, 4 Aug 2025 21:12:53 +0200 Subject: [PATCH 1/3] feat: add ansible task testing infrastructure based on Docker and pytest This complements the existing AMI tests in testinfra by providing a faster feedback loops for Ansible development without requiring a full VM. We are also using testinfra to validate that the Ansible tasks have the desired effect. It is based on Docker, it can be run locally (e.g. macOS) or in CI. Note that this approach is not intended to replace the AMI tests, but rather to provide a more efficient way to test Ansible tasks during development. You can run the tests using `nix run -L .\#ansible-test` --- .github/workflows/ansible-tests.yml | 98 ++++++++++++++++++++++++++++ ansible/tasks/files | 1 + ansible/tests/conftest.py | 82 +++++++++++++++++++++++ ansible/tests/nginx.yaml | 14 ++++ ansible/tests/test_nginx.py | 11 ++++ nix/packages/ansible-test.nix | 23 +++++++ nix/packages/default.nix | 5 ++ nix/packages/docker-ansible-test.nix | 21 ++++++ nix/packages/docker-ubuntu.nix | 57 ++++++++++++++++ 9 files changed, 312 insertions(+) create mode 100644 .github/workflows/ansible-tests.yml create mode 120000 ansible/tasks/files create mode 100644 ansible/tests/conftest.py create mode 100644 ansible/tests/nginx.yaml create mode 100644 ansible/tests/test_nginx.py create mode 100644 nix/packages/ansible-test.nix create mode 100644 nix/packages/docker-ansible-test.nix create mode 100644 nix/packages/docker-ubuntu.nix diff --git a/.github/workflows/ansible-tests.yml b/.github/workflows/ansible-tests.yml new file mode 100644 index 000000000..8cc82a5a1 --- /dev/null +++ b/.github/workflows/ansible-tests.yml @@ -0,0 +1,98 @@ +name: Ansible Test Image CI + +on: + push: + branches: + - develop + pull_request: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + build-and-push: + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + strategy: + matrix: + arch: [amd64, arm64] + runs-on: ${{ matrix.arch == 'amd64' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-16vcpu-ubuntu-2404-arm' }} + steps: + - name: Checkout Repo + uses: supabase/postgres/.github/actions/shared-checkout@HEAD + + - name: Install Nix + uses: ./.github/actions/nix-install-ephemeral + with: + push-to-cache: true + env: + DEV_AWS_ROLE: ${{ secrets.DEV_AWS_ROLE }} + NIX_SIGN_SECRET_KEY: ${{ secrets.NIX_SIGN_SECRET_KEY }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build Docker image with Nix + run: | + echo "Building ansible-test Docker image for ${{ matrix.arch }}..." + IMAGE_PATH=$(nix build .#docker-ansible-test --print-out-paths) + echo "IMAGE_PATH=$IMAGE_PATH" >> "$GITHUB_ENV" + + - name: Load and push Docker image + run: | + echo "Loading Docker image..." + docker load < "$IMAGE_PATH" + docker tag supabase/ansible-test:latest supabase/ansible-test:latest-${{ matrix.arch }} + docker push supabase/ansible-test:latest-${{ matrix.arch }} + + create-manifest: + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + needs: build-and-push + runs-on: 'blacksmith-4vcpu-ubuntu-2404' + steps: + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Create and push multi-arch manifest + run: | + docker manifest create supabase/ansible-test:latest \ + supabase/ansible-test:latest-amd64 \ + supabase/ansible-test:latest-arm64 + docker manifest push supabase/ansible-test:latest + + run-ansible-tests: + if: github.event_name == 'pull_request' || success() + needs: create-manifest + runs-on: 'blacksmith-16vcpu-ubuntu-2404' + steps: + - name: Checkout Repo + uses: supabase/postgres/.github/actions/shared-checkout@HEAD + + - name: Install Nix + uses: ./.github/actions/nix-install-ephemeral + with: + push-to-cache: true + env: + DEV_AWS_ROLE: ${{ secrets.DEV_AWS_ROLE }} + NIX_SIGN_SECRET_KEY: ${{ secrets.NIX_SIGN_SECRET_KEY }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Run Ansible tests + env: + PY_COLORS: '1' + ANSIBLE_FORCE_COLOR: '1' + run: | + docker pull supabase/ansible-test:latest & + nix run .#ansible-test diff --git a/ansible/tasks/files b/ansible/tasks/files new file mode 120000 index 000000000..feb122881 --- /dev/null +++ b/ansible/tasks/files @@ -0,0 +1 @@ +../files \ No newline at end of file diff --git a/ansible/tests/conftest.py b/ansible/tests/conftest.py new file mode 100644 index 000000000..28544f786 --- /dev/null +++ b/ansible/tests/conftest.py @@ -0,0 +1,82 @@ +import pytest +import subprocess +import testinfra +from rich.console import Console + +console = Console() + + +def pytest_addoption(parser): + parser.addoption( + "--flake-dir", + action="store", + help="Directory containing the current flake", + ) + + parser.addoption( + "--docker-image", + action="store", + help="Docker image and tag to use for testing", + ) + + +@pytest.fixture(scope="module") +def host(request): + flake_dir = request.config.getoption("--flake-dir") + if not flake_dir: + pytest.fail("--flake-dir option is required") + docker_image = request.config.getoption("--docker-image") + docker_id = ( + subprocess.check_output( + [ + "docker", + "run", + "--privileged", + "--cap-add", + "SYS_ADMIN", + "--security-opt", + "seccomp=unconfined", + "--cgroup-parent=docker.slice", + "--cgroupns", + "private", + "-v", + f"{flake_dir}:/flake", + "-d", + docker_image, + ] + ) + .decode() + .strip() + ) + yield testinfra.get_host("docker://" + docker_id) + subprocess.check_call(["docker", "rm", "-f", docker_id], stdout=subprocess.DEVNULL) + + +@pytest.fixture(scope="module") +def run_ansible_playbook(host): + def _run_playbook(playbook_name, verbose=False): + cmd = [ + "ANSIBLE_HOST_KEY_CHECKING=False", + "ansible-playbook", + "--connection=local", + ] + if verbose: + cmd.append("-vvv") + cmd.extend( + [ + "-i", + "localhost,", + "--extra-vars", + "@/flake/ansible/vars.yml", + f"/flake/ansible/tests/{playbook_name}", + ] + ) + result = host.run(" ".join(cmd)) + if result.failed: + console.log(result.stdout) + console.log(result.stderr) + pytest.fail( + f"Ansible playbook {playbook_name} failed with return code {result.rc}" + ) + + return _run_playbook diff --git a/ansible/tests/nginx.yaml b/ansible/tests/nginx.yaml new file mode 100644 index 000000000..720e79679 --- /dev/null +++ b/ansible/tests/nginx.yaml @@ -0,0 +1,14 @@ +--- +- hosts: localhost + tasks: + - name: Install dependencies + apt: + pkg: + - build-essential + update_cache: yes + - import_tasks: ../tasks/setup-nginx.yml + - name: Start Nginx service + service: + name: nginx + state: started + enabled: yes diff --git a/ansible/tests/test_nginx.py b/ansible/tests/test_nginx.py new file mode 100644 index 000000000..ec68e82a9 --- /dev/null +++ b/ansible/tests/test_nginx.py @@ -0,0 +1,11 @@ +import pytest + + +@pytest.fixture(scope="module", autouse=True) +def run_ansible(run_ansible_playbook): + run_ansible_playbook("nginx.yaml") + + +def test_nginx_service(host): + assert host.service("nginx.service").is_valid + assert host.service("nginx.service").is_running diff --git a/nix/packages/ansible-test.nix b/nix/packages/ansible-test.nix new file mode 100644 index 000000000..618ecc1c9 --- /dev/null +++ b/nix/packages/ansible-test.nix @@ -0,0 +1,23 @@ +{ self, pkgs }: +pkgs.writeShellApplication { + name = "ansible-test"; + runtimeInputs = with pkgs; [ + (python3.withPackages ( + ps: with ps; [ + requests + pytest + pytest-testinfra + pytest-xdist + rich + ] + )) + ]; + text = '' + echo "Running Ansible tests..." + FLAKE_DIR=${self} + pytest -x -p no:cacheprovider -s -v "$@" $FLAKE_DIR/ansible/tests --flake-dir=$FLAKE_DIR --docker-image=supabase/ansible-test:latest "$@" + ''; + meta = { + description = "Ansible test runner"; + }; +} diff --git a/nix/packages/default.nix b/nix/packages/default.nix index c8eb02ef0..2a58f95ad 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -30,8 +30,13 @@ packages = ( { build-test-ami = pkgs.callPackage ./build-test-ami.nix { }; + ansible-test = pkgs.callPackage ./ansible-test.nix { inherit self; }; cleanup-ami = pkgs.callPackage ./cleanup-ami.nix { }; dbmate-tool = pkgs.callPackage ./dbmate-tool.nix { inherit (self.supabase) defaults; }; + docker-ansible-test = pkgs.callPackage ./docker-ansible-test.nix { + inherit (self'.packages) docker-image-ubuntu; + }; + docker-image-ubuntu = pkgs.callPackage ./docker-ubuntu.nix { }; docs = pkgs.callPackage ./docs.nix { }; supabase-groonga = pkgs.callPackage ./groonga { }; http-mock-server = pkgs.callPackage ./http-mock-server.nix { }; diff --git a/nix/packages/docker-ansible-test.nix b/nix/packages/docker-ansible-test.nix new file mode 100644 index 000000000..15a1d2320 --- /dev/null +++ b/nix/packages/docker-ansible-test.nix @@ -0,0 +1,21 @@ +{ + pkgs, + lib, + docker-image-ubuntu, +}: +let + tools = [ pkgs.ansible ]; +in +pkgs.dockerTools.buildLayeredImage { + name = "supabase/ansible-test"; + tag = "latest"; + maxLayers = 30; + fromImage = docker-image-ubuntu; + compressor = "zstd"; + config = { + Env = [ + "PATH=${lib.makeBinPath tools}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ]; + Cmd = [ "/lib/systemd/systemd" ]; + }; +} diff --git a/nix/packages/docker-ubuntu.nix b/nix/packages/docker-ubuntu.nix new file mode 100644 index 000000000..c492380f9 --- /dev/null +++ b/nix/packages/docker-ubuntu.nix @@ -0,0 +1,57 @@ +{ + runCommand, + dockerTools, + xz, + buildEnv, + stdenv, +}: +let + ubuntu-cloudimg = + let + + cloudImg = + if stdenv.hostPlatform.system == "x86_64-linux" then + builtins.fetchurl { + url = "https://cloud-images.ubuntu.com/releases/noble/release-20251026/ubuntu-24.04-server-cloudimg-amd64-root.tar.xz"; + sha256 = "0y3d55f5qy7bxm3mfmnxzpmwp88d7iiszc57z5b9npc6xgwi28np"; + } + else + builtins.fetchurl { + url = "https://cloud-images.ubuntu.com/releases/noble/release-20251026/ubuntu-24.04-server-cloudimg-arm64-root.tar.xz"; + sha256 = "1l4l0llfffspzgnmwhax0fcnjn8ih8n4azhfaghng2hh1xvr4a17"; + }; + in + runCommand "ubuntu-cloudimg" { nativeBuildInputs = [ xz ]; } '' + mkdir -p $out + tar --exclude='dev/*' \ + --exclude='etc/systemd/system/network-online.target.wants/systemd-networkd-wait-online.service' \ + --exclude='etc/systemd/system/multi-user.target.wants/systemd-resolved.service' \ + --exclude='usr/lib/systemd/system/tpm-udev.service' \ + --exclude='usr/lib/systemd/system/systemd-remount-fs.service' \ + --exclude='usr/lib/systemd/system/systemd-resolved.service' \ + --exclude='usr/lib/systemd/system/proc-sys-fs-binfmt_misc.automount' \ + --exclude='usr/lib/systemd/system/sys-kernel-*' \ + --exclude='var/lib/apt/lists/*' \ + -xJf ${cloudImg} -C $out + rm -f $out/bin $out/lib $out/lib64 $out/sbin + mkdir -p $out/run/systemd && echo 'docker' > $out/run/systemd/container + mkdir $out/var/lib/apt/lists/partial + ''; +in +dockerTools.buildImage { + name = "ubuntu-cloudimg"; + tag = "24.04"; + created = "now"; + extraCommands = '' + ln -s usr/bin + ln -s usr/lib + ln -s usr/lib64 + ln -s usr/sbin + ''; + copyToRoot = buildEnv { + name = "image-root"; + pathsToLink = [ "/" ]; + paths = [ ubuntu-cloudimg ]; + }; + config.Cmd = [ "/lib/systemd/systemd" ]; +} From b22316b971b5d63b29343de8e537ec05ff0ea356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Tue, 11 Nov 2025 15:14:18 +0100 Subject: [PATCH 2/3] action lint --- .github/actionlint.yml | 8 ++++++++ nix/hooks.nix | 12 ++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 .github/actionlint.yml diff --git a/.github/actionlint.yml b/.github/actionlint.yml new file mode 100644 index 000000000..9b6706dff --- /dev/null +++ b/.github/actionlint.yml @@ -0,0 +1,8 @@ +self-hosted-runner: + labels: + - aarch64-darwin + - aarch64-linux + - blacksmith-2vcpu-ubuntu-2404-arm + - blacksmith-4vcpu-ubuntu-2404 + - blacksmith-16vcpu-ubuntu-2404 + - blacksmith-32vcpu-ubuntu-2404 diff --git a/nix/hooks.nix b/nix/hooks.nix index 896c262ba..90b11c9b7 100644 --- a/nix/hooks.nix +++ b/nix/hooks.nix @@ -1,4 +1,8 @@ { inputs, ... }: +let + ghWorkflows = builtins.attrNames (builtins.readDir ../.github/workflows); + lintedWorkflows = [ "ansible-test.yml" ]; +in { imports = [ inputs.git-hooks.flakeModule ]; perSystem = @@ -8,9 +12,17 @@ check.enable = true; settings = { hooks = { + actionlint = { + enable = true; + excludes = builtins.filter (name: !builtins.elem name lintedWorkflows) ghWorkflows; + verbose = true; + }; + treefmt = { enable = true; package = config.treefmt.build.wrapper; + pass_filenames = false; + verbose = true; }; }; }; From d608ca0e63cb56c979b453b455f0f7ebde40a780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Tue, 11 Nov 2025 15:14:18 +0100 Subject: [PATCH 3/3] feat: add ansible-lint validation for test playbooks Configure pre-commit hook to run ansible-lint on test playbooks and their dependencies. Since test playbooks include tasks from existing task files, ansible-lint automatically validates those dependencies as well. --- .ansible-lint.yml | 38 +++++++++++++++++++ ansible/tasks/setup-nginx.yml | 70 ++++++++++++++++++----------------- ansible/tests/nginx.yaml | 26 +++++++------ nix/hooks.nix | 9 +++++ 4 files changed, 98 insertions(+), 45 deletions(-) create mode 100644 .ansible-lint.yml diff --git a/.ansible-lint.yml b/.ansible-lint.yml new file mode 100644 index 000000000..e010e6090 --- /dev/null +++ b/.ansible-lint.yml @@ -0,0 +1,38 @@ +--- +profile: production + +# exclude_paths included in this file are parsed relative to this file's location +# and not relative to the CWD of execution. CLI arguments passed to the --exclude +# option are parsed relative to the CWD of execution. +exclude_paths: + - .cache/ # implicit unless exclude_paths is defined in config + - .github/ + +use_default_rules: true +enable_list: + - args + - empty-string-compare + - no-log-password + - no-same-owner +warn_list: + - experimental +skip_list: + - name[casing] + - name[prefix] + - yaml[line-length] + - var-naming[no-role-prefix] + +# Offline mode disables installation of requirements.yml +offline: false + +# Make the output more readable +parseable: true + +# Define required Ansible's variables to satisfy syntax check +# extra_vars: + +# List of additional kind:pattern to be added at the top of the default +# match list, first match determines the file kind. +kinds: + - tasks: "ansible/tasks/*.yml" + - vars: "ansible/vars.yml" diff --git a/ansible/tasks/setup-nginx.yml b/ansible/tasks/setup-nginx.yml index 1f10ceec2..58986419a 100644 --- a/ansible/tasks/setup-nginx.yml +++ b/ansible/tasks/setup-nginx.yml @@ -1,10 +1,11 @@ -- name: nginx - system user +--- +- name: Nginx - system user ansible.builtin.user: - name: 'nginx' - state: 'present' + name: nginx + state: present # Kong installation steps from http://archive.vn/3HRQx -- name: nginx - system dependencies +- name: Nginx - system dependencies ansible.builtin.apt: pkg: - libpcre3-dev @@ -12,67 +13,70 @@ - openssl - zlib1g-dev -- name: nginx - download source +- name: Nginx - download source ansible.builtin.get_url: checksum: "{{ nginx_release_checksum }}" - dest: '/tmp/nginx-{{ nginx_release }}.tar.gz' - url: "https://nginx.org/download/nginx-{{ nginx_release }}.tar.gz" + dest: /tmp/nginx-{{ nginx_release }}.tar.gz + url: https://nginx.org/download/nginx-{{ nginx_release }}.tar.gz + mode: '0640' -- name: nginx - unpack archive +- name: Nginx - unpack archive ansible.builtin.unarchive: - dest: '/tmp' + dest: /tmp remote_src: true - src: "/tmp/nginx-{{ nginx_release }}.tar.gz" + src: /tmp/nginx-{{ nginx_release }}.tar.gz -- name: nginx - configure +- name: Nginx - configure ansible.builtin.command: argv: - - ./configure - - --prefix=/usr/local/nginx - - --conf-path=/etc/nginx/nginx.conf - - --with-http_ssl_module - - --with-http_realip_module + - ./configure + - --prefix=/usr/local/nginx + - --conf-path=/etc/nginx/nginx.conf + - --with-http_ssl_module + - --with-http_realip_module - --with-threads + creates: /tmp/nginx-{{ nginx_release }}/Makefile args: - chdir: "/tmp/nginx-{{ nginx_release }}" + chdir: /tmp/nginx-{{ nginx_release }} become: true -- name: nginx - build and install +- name: Nginx - build and install community.general.make: - chdir: "/tmp/nginx-{{ nginx_release }}" + chdir: /tmp/nginx-{{ nginx_release }} jobs: "{{ parallel_jobs | default(omit) }}" target: "{{ make_target }}" become: true loop: - - 'build' - - 'install' + - build + - install loop_control: - loop_var: 'make_target' + loop_var: make_target -- name: nginx - hand over ownership of /etc/nginx and /usr/local/nginx to user nginx +- name: Nginx - hand over ownership of /etc/nginx and /usr/local/nginx to user nginx ansible.builtin.file: - owner: 'nginx' + owner: nginx path: "{{ nginx_dir_item }}" recurse: true loop: - /etc/nginx - /usr/local/nginx loop_control: - loop_var: 'nginx_dir_item' + loop_var: nginx_dir_item # [warn] ulimit is currently set to "1024". For better performance set it to at least # "4096" using "ulimit -n" -- name: nginx - bump up ulimit +- name: Nginx - bump up ulimit community.general.pam_limits: - domain: 'nginx' - limit_item: 'nofile' - limit_type: 'soft' - value: '4096' + domain: nginx + limit_item: nofile + limit_type: soft + value: "4096" -- name: nginx - create service file +- name: Nginx - create service file ansible.builtin.template: - dest: '/etc/systemd/system/nginx.service' - src: 'files/nginx.service.j2' + dest: /etc/systemd/system/nginx.service + src: files/nginx.service.j2 + mode: '0644' # Keep it dormant for the timebeing diff --git a/ansible/tests/nginx.yaml b/ansible/tests/nginx.yaml index 720e79679..0054819c1 100644 --- a/ansible/tests/nginx.yaml +++ b/ansible/tests/nginx.yaml @@ -1,14 +1,16 @@ --- -- hosts: localhost +- name: Setup Nginx Server + hosts: localhost tasks: - - name: Install dependencies - apt: - pkg: - - build-essential - update_cache: yes - - import_tasks: ../tasks/setup-nginx.yml - - name: Start Nginx service - service: - name: nginx - state: started - enabled: yes + - name: Install dependencies + ansible.builtin.apt: + pkg: + - build-essential + update_cache: true + - name: Setup Nginx using existing task file + ansible.builtin.import_tasks: ../tasks/setup-nginx.yml + - name: Start Nginx service + ansible.builtin.service: + name: nginx + state: started + enabled: true diff --git a/nix/hooks.nix b/nix/hooks.nix index 90b11c9b7..1552e1638 100644 --- a/nix/hooks.nix +++ b/nix/hooks.nix @@ -18,6 +18,15 @@ in verbose = true; }; + ansible-lint = { + enable = true; + verbose = true; + settings = { + configPath = "${../.ansible-lint.yml}"; + subdir = "ansible/tests"; + }; + }; + treefmt = { enable = true; package = config.treefmt.build.wrapper;