From 84082ed982522f073f45c76fb2dce3a60e6eea44 Mon Sep 17 00:00:00 2001
From: Brian Haley <haleyb.dev@gmail.com>
Date: Thu, 23 Jan 2025 16:21:47 -0500
Subject: [PATCH] Update ensure-twine role

Install twine into a venv and set appropriate environment
variables. Also added tests.

Based on commit adding `ensure-nox` (77b1b24) role.

Related-bug: #2095514
Change-Id: Ibb4e89f79879b4d0ae0294440c9c0b79fc57a7fa
---
 roles/ensure-twine/README.rst         | 35 +++++++++----
 roles/ensure-twine/defaults/main.yaml |  8 +--
 roles/ensure-twine/tasks/main.yaml    | 46 +++++++++++-----
 test-playbooks/ensure-twine.yaml      | 35 +++++++++++++
 zuul-tests.d/python-jobs.yaml         | 75 +++++++++++++++++++++++++++
 5 files changed, 174 insertions(+), 25 deletions(-)
 create mode 100644 test-playbooks/ensure-twine.yaml

diff --git a/roles/ensure-twine/README.rst b/roles/ensure-twine/README.rst
index 9f0a62bb5..f19ee85e9 100644
--- a/roles/ensure-twine/README.rst
+++ b/roles/ensure-twine/README.rst
@@ -1,27 +1,42 @@
 Ensure twine is installed.
 
-This role is designed to run without permissions, so assumes a working
-Python 3 ``pip`` environment (i.e. it will not install system
-packages).
+Look for ``twine``, and if not found, install it via ``pip`` into a
+virtual environment for the current user.
 
 **Role Variables**
 
-.. zuul:rolevar:: twine_python
-   :default: python
+.. zuul:rolevar:: ensure_twine_version
+   :default: ''
 
-   The python interpreter to use to install twine if it is not already
-   installed. Set it to "python3" to use python 3, for example.
+   Version specifier to select the version of Twine.  The default is the
+   latest version.
+
+.. zuul:rolevar:: ensure_twine_venv_path
+   :default: {{ ansible_user_dir }}/.local/twine
+
+   Directory for the Python venv where Twine will be installed.
+
+.. zuul:rolevar:: ensure_twine_global_symlink
+   :default: False
+
+   Install a symlink to the twine executable into ``/usr/local/bin/twine``.
+   This can be useful when scripts need to be run that expect to find
+   Twine in a more standard location and plumbing through the value
+   of ``pypi_twine_executable`` would be onerous.
+
+   Setting this requires root access, so should only be done in
+   circumstances where root access is available.
 
 **Output Variables**
 
-.. zuul:rolevar:: twine_excutable
+.. zuul:rolevar:: pypi_twine_executable
    :default: twine
 
-   After running this role, ``twine_executable`` will be set as the path
+   After running this role, ``pypi_twine_executable`` will be set as the path
    to a valid ``twine``.
 
    At role runtime, look for an existing ``twine`` at this specific
-   path.  Note the default (``twine``) effectively means to find tox in
+   path.  Note the default (``twine``) effectively means to find twine in
    the current ``$PATH``.  For example, if your base image
    pre-installs twine in an out-of-path environment, set this so the
    role does not attempt to install the user version.
diff --git a/roles/ensure-twine/defaults/main.yaml b/roles/ensure-twine/defaults/main.yaml
index 6c1ecf3a5..160d497ac 100644
--- a/roles/ensure-twine/defaults/main.yaml
+++ b/roles/ensure-twine/defaults/main.yaml
@@ -1,3 +1,5 @@
----
-twine_python: python3
-twine_executable: twine
+ensure_twine_global_symlink: false
+# version 6.1.0 is breaking test-release-openstack CI job
+ensure_twine_version: ">1.12.0,!=6.1.0"
+pypi_twine_executable: twine
+ensure_twine_venv_path: "{{ ansible_user_dir }}/.local/twine"
diff --git a/roles/ensure-twine/tasks/main.yaml b/roles/ensure-twine/tasks/main.yaml
index 2c5bc6f84..3727fc4ab 100644
--- a/roles/ensure-twine/tasks/main.yaml
+++ b/roles/ensure-twine/tasks/main.yaml
@@ -1,22 +1,44 @@
-- name: Check for twine install
+- name: Install pip
+  include_role:
+    name: ensure-pip
+
+- name: Check if twine is installed
   shell: |
-    command -v {{ twine_executable }} ~/.local/bin/twine || exit 1
+    command -v {{ pypi_twine_executable }} {{ ensure_twine_venv_path }}/bin/twine || exit 1
   args:
     executable: /bin/bash
+  register: twine_preinstalled
   failed_when: false
-  register: register_twine
 
-- name: Set pypi_twine_executable
+- name: Export preinstalled pypi_twine_executable
   set_fact:
-    pypi_twine_executable: "{{ register_twine.stdout_lines[0] }}"
-  when: register_twine.rc == 0
+    pypi_twine_executable: "{{ twine_preinstalled.stdout_lines[0] }}"
+    cacheable: true
+  when: twine_preinstalled.rc == 0
 
-- name: Ensure twine is installed
-  when: register_twine.rc != 0
+- name: Install twine to local env
+  when: twine_preinstalled.rc != 0
   block:
-    - name: Ensure twine is installed
-      command: "{{ twine_python }} -m pip install twine!=1.12.0,!=6.1.0 requests-toolbelt!=0.9.0 --user"
+    - name: Create local venv
+      command: "{{ ensure_pip_virtualenv_command }} {{ ensure_twine_venv_path }}"
 
-    - name: Set pypi_twine_executable
+    - name: Install twine to local venv
+      command: "{{ ensure_twine_venv_path }}/bin/pip install twine{{ ensure_twine_version }}"
+
+    - name: Export installed pypi_twine_executable path
       set_fact:
-        pypi_twine_executable: ~/.local/bin/twine
+        pypi_twine_executable: "{{ ensure_twine_venv_path }}/bin/twine"
+        cacheable: true
+
+- name: Output twine version
+  command: "{{ pypi_twine_executable }} --version"
+
+- name: Make global symlink
+  when:
+    - ensure_twine_global_symlink
+    - pypi_twine_executable != '/usr/local/bin/twine'
+  file:
+    state: link
+    src: "{{ pypi_twine_executable }}"
+    dest: /usr/local/bin/twine
+  become: yes
diff --git a/test-playbooks/ensure-twine.yaml b/test-playbooks/ensure-twine.yaml
new file mode 100644
index 000000000..5a47d480f
--- /dev/null
+++ b/test-playbooks/ensure-twine.yaml
@@ -0,0 +1,35 @@
+- hosts: all
+  name: Test ensure-twine installs into user environment
+  tasks:
+    - name: Verify twine is not installed
+      command: "twine --version"
+      register: result
+      failed_when: result.rc == 0
+    - name: Run ensure-twine with twine not installed
+      include_role:
+        name: ensure-twine
+    - name: Verify pypi_twine_executable is set
+      assert:
+        that:
+          - pypi_twine_executable == ansible_user_dir + '/.local/twine/bin/twine'
+    - name: Verify twine is installed
+      command: "{{ pypi_twine_executable }} --version"
+      register: result
+      failed_when: result.rc != 0
+
+- hosts: all
+  name: Test ensure-twine when pypi_twine_executable is set to an already installed twine
+  tasks:
+    - name: Create a virtualenv
+      command: '{{ ensure_pip_virtualenv_command }} {{ ansible_user_dir }}/twine-venv'
+    - name: Install twine to local venv
+      command: '{{ ansible_user_dir }}/twine-venv/bin/pip install twine'
+    - name: Run ensure-twine pointing to an already installed twine
+      include_role:
+        name: ensure-twine
+      vars:
+        pypi_twine_executable: "{{ ansible_user_dir }}/twine-venv/bin/twine"
+    - name: Verify pypi_twine_executable is set to the virtualenv twine
+      assert:
+        that:
+          - pypi_twine_executable == ansible_user_dir + '/twine-venv/bin/twine'
diff --git a/zuul-tests.d/python-jobs.yaml b/zuul-tests.d/python-jobs.yaml
index c31e907e9..61270afa4 100644
--- a/zuul-tests.d/python-jobs.yaml
+++ b/zuul-tests.d/python-jobs.yaml
@@ -282,6 +282,75 @@
         - name: ubuntu-noble
           label: ubuntu-noble
 
+- job:
+    name: zuul-jobs-test-ensure-twine
+    description: Test the ensure-twine role
+    files:
+      - roles/ensure-twine/.*
+      - test-playbooks/ensure-twine.yaml
+    run: test-playbooks/ensure-twine.yaml
+    tags: all-platforms
+
+- job:
+    name: zuul-jobs-test-ensure-twine-centos-9-stream
+    description: Test the ensure-twine role on centos-9-stream
+    parent: zuul-jobs-test-ensure-twine
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: centos-9-stream
+          label: centos-9-stream
+
+- job:
+    name: zuul-jobs-test-ensure-twine-debian-bookworm
+    description: Test the ensure-twine role on debian-bookworm
+    parent: zuul-jobs-test-ensure-twine
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: debian-bookworm
+          label: debian-bookworm
+
+- job:
+    name: zuul-jobs-test-ensure-twine-debian-bullseye
+    description: Test the ensure-twine role on debian-bullseye
+    parent: zuul-jobs-test-ensure-twine
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: debian-bullseye
+          label: debian-bullseye
+
+- job:
+    name: zuul-jobs-test-ensure-twine-ubuntu-focal
+    description: Test the ensure-twine role on ubuntu-focal
+    parent: zuul-jobs-test-ensure-twine
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: ubuntu-focal
+          label: ubuntu-focal
+
+- job:
+    name: zuul-jobs-test-ensure-twine-ubuntu-jammy
+    description: Test the ensure-twine role on ubuntu-jammy
+    parent: zuul-jobs-test-ensure-twine
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: ubuntu-jammy
+          label: ubuntu-jammy
+
+- job:
+    name: zuul-jobs-test-ensure-twine-ubuntu-noble
+    description: Test the ensure-twine role on ubuntu-noble
+    parent: zuul-jobs-test-ensure-twine
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: ubuntu-noble
+          label: ubuntu-noble
+
 - job:
     name: zuul-jobs-test-ensure-sphinx
     description: Test the ensure-sphinx role
@@ -570,6 +639,12 @@
         - zuul-jobs-test-ensure-pyproject-build-ubuntu-focal
         - zuul-jobs-test-ensure-pyproject-build-ubuntu-jammy
         - zuul-jobs-test-ensure-pyproject-build-ubuntu-noble
+        - zuul-jobs-test-ensure-twine-centos-9-stream
+        - zuul-jobs-test-ensure-twine-debian-bookworm
+        - zuul-jobs-test-ensure-twine-debian-bullseye
+        - zuul-jobs-test-ensure-twine-ubuntu-focal
+        - zuul-jobs-test-ensure-twine-ubuntu-jammy
+        - zuul-jobs-test-ensure-twine-ubuntu-noble
         - zuul-jobs-test-ensure-sphinx
         - zuul-jobs-test-ensure-tox-centos-9-stream
         - zuul-jobs-test-ensure-tox-debian-bookworm