Repository: teemtee/tmt (language: python)

Issue:
Introduce a new `prepare` plugin for common features
As the first step towards a consistent way to prepare guests for testing a new `feature` plugin for the `prepare` step was discussed. The expected config syntax looks like this:

```yaml
prepare:
    how: feature
    epel: enabled
    crb: disabled
```

Similarly there should be a L1 key `feature` which could be used
to enabled features for individual tests:

```yaml
test: ./test.sh
feature:
    epel: enabled
    fips: enabled
```

For the implementation an ansible role with playbooks covering individual features should be used.


Discussion / hints:
> For the implementation an ansible role with playbooks covering individual features should be used.

There are some limitations for `ansible` implementation on `rhel` (e.g. only some modules available). Double-check this before implementing as we need this to be working reliable across all supported distros.
@psss, I'll look into this issue.
> @psss, I'll look into this issue.

Great! As the first proof of concept you can start with creating just the new `feature` prepare plugin (we can keep the L1 functionality for later). The [install](https://github.com/teemtee/tmt/blob/main/tmt/steps/prepare/install.py) plugin can serve you as a good template for start.
Before implementing this plugin, let me explain the three abbreviations listed above from my understanding,
* `epel`: **Extra Packages for Enterprise Linux**. For details, please refer to doc [What is Extra Packages for Enterprise Linux \(or EPEL\)?](https://docs.fedoraproject.org/en-US/epel/#what_is_extra_packages_for_enterprise_linux_or_epel)
   > Extra Packages for Enterprise Linux (or EPEL) is a Fedora Special Interest Group that creates, maintains, and manages a high quality set of additional packages for Enterprise Linux, including, but not limited to, [Red Hat Enterprise Linux](https://docs.fedoraproject.org/en-US/quick-docs/fedora-and-red-hat-enterprise-linux/index.html) (RHEL), CentOS, Scientific Linux (SL), Oracle Linux (OL), AlmaLinux (AL) and Rocky Linux (RL) ......
* `crb`: **CodeReady Linux Builder**. For details, please refer to doc [Chapter 3. The CodeReady Linux Builder repository](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/package_manifest/codereadylinuxbuilder-repository)
    > The CodeReady Linux Builder repository contains additional packages for use by developers. This repository is available with all Red Hat Enterprise Linux subscriptions but does not need to be enabled on your runtime deployments. Packages included in the CodeReady Linux Builder repository are unsupported ......
* `fips`: **Federal Information Processing Standard**. For details, please refer to doc [Chapter 3. Installing a RHEL 8 system with FIPS mode enabled](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/security_hardening/assembly_installing-a-rhel-8-system-with-fips-mode-enabled_security-hardening)
   > The Federal Information Processing Standard (FIPS) Publication 140-2 is a computer security standard developed by the U.S. Government and industry working group to validate the quality of cryptographic modules ......

@psss, if what I understood on `epel`, `crb` and `fips` is not exactly right, please help to correct me, thanks!

@psss, would you please help to explain what should to be done on guest if `epel` is `enabled`? 
From my understanding, if `epel` is `enabled`, we should try to install the package `epel-release` on guest, right?

> @psss, if what I understood on `epel`, `crb` and `fips` is not exactly right, please help to correct me, thanks!

Yes, the summaries and links are correct.

> @psss, would you please help to explain what should to be done on guest if `epel` is `enabled`?

I'd say we should follow the [quickstart instructions](https://docs.fedoraproject.org/en-US/epel/#_quickstart) from the official documentation. They clearly specify which specific steps need to be performed on different rhel/centos versions.

> From my understanding, if `epel` is `enabled`, we should try to install the package `epel-release` on guest, right?

Yes, basically making sure that the `epel-release` is installed on the guest and the repository is enabled.

Fix patch:
diff --git a/docs/releases.rst b/docs/releases.rst
index d84242ee04..40296b1630 100644
--- a/docs/releases.rst
+++ b/docs/releases.rst
@@ -44,6 +44,10 @@ New hardware specification key :ref:`/spec/hardware/zcrypt` has
 been defined. It will be used for selecting guests with the given
 `System z cryptographic adapter`.
 
+A prepare step plugin :ref:`/spec/plans/prepare/feature` has been
+implemented. As the first supported feature, ``epel`` repositories
+can now be enabled using a concise configuration.
+
 
 tmt-1.30
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/spec/plans/prepare.fmf b/spec/plans/prepare.fmf
index 1ab36dccbc..17c7bd55a5 100644
--- a/spec/plans/prepare.fmf
+++ b/spec/plans/prepare.fmf
@@ -86,6 +86,25 @@ example: |
       - implemented-by: /tmt/steps/provision
       - verified-by: /tests/prepare/ansible
 
+/feature:
+    summary:
+        Easily enable and disable common features
+    description: |
+        The ``feature`` plugin provides a comfortable way to
+        enable and disable some commonly used functionality. As
+        for now enabling and disabling the ``epel`` repository is
+        supported, ``crb`` and ``fips`` are coming in the near
+        future.
+
+        .. versionadded:: 1.31
+    example: |
+        prepare:
+            how: feature
+            epel: enabled
+    link:
+      - implemented-by: /tmt/steps/prepare/feature
+      - verified-by: /tests/prepare/feature
+
 /install:
     summary:
         Install packages on the guest
diff --git a/tmt/schemas/prepare/feature.yaml b/tmt/schemas/prepare/feature.yaml
new file mode 100644
index 0000000000..af90c2e09b
--- /dev/null
+++ b/tmt/schemas/prepare/feature.yaml
@@ -0,0 +1,29 @@
+---
+
+#
+# JSON Schema definition for `feature` prepare plugin
+#
+# https://tmt.readthedocs.io/en/stable/spec/plans.html#spec-plans-prepare-feature
+#
+
+$id: /schemas/prepare/feature
+$schema: https://json-schema.org/draft-07/schema
+
+type: object
+additionalProperties: false
+
+properties:
+
+  how:
+    type: string
+    enum:
+      - feature
+
+  epel:
+    type: string
+    enum:
+      - enabled
+      - disabled
+
+required:
+  - how
diff --git a/tmt/steps/prepare/feature.py b/tmt/steps/prepare/feature.py
new file mode 100644
index 0000000000..e641166692
--- /dev/null
+++ b/tmt/steps/prepare/feature.py
@@ -0,0 +1,150 @@
+import dataclasses
+from typing import Optional, cast
+
+import tmt
+import tmt.base
+import tmt.log
+import tmt.options
+import tmt.steps
+import tmt.steps.prepare
+import tmt.utils
+from tmt.steps.provision import Guest
+from tmt.utils import Path, field
+
+FEATURE_PLAYEBOOK_DIRECTORY = tmt.utils.resource_files('steps/prepare/feature')
+
+
+class Feature(tmt.utils.Common):
+    """ Base class for ``feature`` prepare plugin implementations """
+
+    NAME: str
+
+    def __init__(
+            self,
+            *,
+            parent: 'PrepareFeature',
+            guest: Guest,
+            logger: tmt.log.Logger) -> None:
+        """ Initialize feature data """
+        super().__init__(logger=logger, parent=parent, relative_indent=0)
+
+        self.guest = guest
+
+    def _find_playbook(self, filename: str) -> Optional[Path]:
+        filepath = FEATURE_PLAYEBOOK_DIRECTORY / filename
+        if filepath.exists():
+            return filepath
+
+        self.warn(f"Cannot find any suitable playbook for '{filename}'.")
+        return None
+
+
+class ToggleableFeature(Feature):
+    def get_root_path(self) -> Path:
+        """ Get the root path for getting the path of a playbook"""
+        assert self.parent is not None  # narrow type
+        assert self.parent.parent is not None  # narrow type
+        assert self.parent.parent.parent is not None  # narrow type
+        plan = cast(tmt.base.Plan, self.parent.parent.parent)
+        assert plan.my_run is not None  # narrow type
+        assert plan.my_run.tree is not None  # narrow type
+        assert plan.my_run.tree.root is not None  # narrow type
+        return plan.my_run.tree.root
+
+    def _run_playbook(self, op: str, playbook_filename: str) -> None:
+        playbook_path = self._find_playbook(playbook_filename)
+        if not playbook_path:
+            raise tmt.utils.GeneralError(
+                f"{op.capitalize()} {self.NAME.upper()} is not supported on this guest.")
+
+        self.info(f'{op.capitalize()} {self.NAME.upper()}')
+        self.guest.ansible(playbook_path.relative_to(self.get_root_path()))
+
+    def _enable(self, playbook_filename: str) -> None:
+        self._run_playbook('enable', playbook_filename)
+
+    def _disable(self, playbook_filename: str) -> None:
+        self._run_playbook('disable', playbook_filename)
+
+    def enable(self) -> None:
+        raise NotImplementedError
+
+    def disable(self) -> None:
+        raise NotImplementedError
+
+
+class EPEL(ToggleableFeature):
+    NAME = 'epel'
+
+    def enable(self) -> None:
+        self._enable('epel-enable.yaml')
+
+    def disable(self) -> None:
+        self._disable('epel-disable.yaml')
+
+
+_FEATURES: dict[str, type[Feature]] = {
+    EPEL.NAME: EPEL
+    }
+
+
+@dataclasses.dataclass
+class PrepareFeatureData(tmt.steps.prepare.PrepareStepData):
+    epel: Optional[str] = field(
+        default=None,
+        option='--epel',
+        metavar='enabled|disabled',
+        help='Whether EPEL repository should be installed & enabled or disabled.'
+        )
+
+
+@tmt.steps.provides_method('feature')
+class PrepareFeature(tmt.steps.prepare.PreparePlugin[PrepareFeatureData]):
+    """
+    Enable or disable common feature ``epel`` on the guest
+
+    Example config:
+
+        prepare:
+            how: feature
+            epel: enabled
+
+        Or
+
+        prepare:
+            how: feature
+            epel: disabled
+    """
+
+    _data_class = PrepareFeatureData
+
+    def go(
+            self,
+            *,
+            guest: 'Guest',
+            environment: Optional[tmt.utils.EnvironmentType] = None,
+            logger: tmt.log.Logger) -> None:
+        """ Prepare the guests """
+        super().go(guest=guest, environment=environment, logger=logger)
+
+        # Nothing to do in dry mode
+        if self.opt('dry'):
+            return
+
+        # Enable or disable epel
+        for feature_key in _FEATURES:
+            value = cast(Optional[str], getattr(self.data, feature_key, None))
+            if value is None:
+                continue
+
+            feature = _FEATURES[feature_key](parent=self, guest=guest, logger=logger)
+            if isinstance(feature, ToggleableFeature):
+                value = value.lower()
+                if value == 'enabled':
+                    feature.enable()
+                elif value == 'disabled':
+                    feature.disable()
+                else:
+                    raise tmt.utils.GeneralError(f"Unknown feature setting '{value}'.")
+            else:
+                raise tmt.utils.GeneralError(f"Unsupported feature '{feature_key}'.")
diff --git a/tmt/steps/prepare/feature/epel-disable.yaml b/tmt/steps/prepare/feature/epel-disable.yaml
new file mode 100644
index 0000000000..7521a05e45
--- /dev/null
+++ b/tmt/steps/prepare/feature/epel-disable.yaml
@@ -0,0 +1,45 @@
+- hosts: all
+  tasks:
+    # RHEL 7 or CentOS 7
+    - when: ansible_distribution_major_version|int == 7
+      block:
+        - name: Install package 'yum-utils'
+          ansible.builtin.yum:
+            name: yum-utils
+            state: present
+
+        # Disable repos of 'epel' if package 'epel-release' is installed
+        - name: Detect package 'epel-release' is installed
+          command: rpm -q epel-release
+          register: result
+          ignore_errors: True
+
+        - name: Disable repos of 'epel'
+          shell: yum-config-manager --disable epel epel-debuginfo epel-source
+          when: result.rc == 0
+
+    # RHEL N or CentOS Stream N where N >= 8
+    - when: ansible_distribution_major_version|int >= 8
+      block:
+        - name: Install 'dnf config-manager'
+          shell: dnf -y install 'dnf-command(config-manager)'
+
+        # Disable repos of 'epel' if package 'epel-release' is installed
+        - name: Detect package 'epel-release' is installed
+          command: rpm -q epel-release
+          register: result
+          ignore_errors: True
+
+        - name: Disable repos of 'epel' if package 'epel-release' is installed
+          shell: dnf config-manager --disable epel epel-debuginfo epel-source
+          when: result.rc == 0
+
+        # Disable repos of 'epel-next' if package 'epel-next-release' is installed
+        - name: Detect package 'epel-next-release' is installed
+          command: rpm -q epel-next-release
+          register: result
+          ignore_errors: True
+
+        - name: Disable repos of 'epel-next'
+          shell: dnf config-manager --disable epel-next epel-next-debuginfo epel-next-source
+          when: result.rc == 0
diff --git a/tmt/steps/prepare/feature/epel-enable.yaml b/tmt/steps/prepare/feature/epel-enable.yaml
new file mode 100644
index 0000000000..26fba44f29
--- /dev/null
+++ b/tmt/steps/prepare/feature/epel-enable.yaml
@@ -0,0 +1,80 @@
+- hosts: all
+  tasks:
+    # RHEL 7
+    - when: ansible_distribution == 'RedHat' and ansible_distribution_major_version|int == 7
+      block:
+        - name: Install package 'epel-release'
+          ansible.builtin.yum:
+            name: "https://dl.fedoraproject.org/pub/epel/epel-release-latest-{{ ansible_distribution_major_version }}.noarch.rpm"
+            disable_gpg_check: true
+            state: present
+
+        - name: Install package 'yum-utils'
+          ansible.builtin.yum:
+            name: yum-utils
+            state: present
+
+        - name: Enable repos of 'epel'
+          shell: yum-config-manager --enable epel epel-debuginfo epel-source
+
+    # RHEL N, where N >= 8
+    - when: ansible_distribution == "RedHat" and ansible_distribution_major_version|int >= 8
+      block:
+        - name: Install package 'epel-release'
+          ansible.builtin.yum:
+            name: "https://dl.fedoraproject.org/pub/epel/epel-release-latest-{{ ansible_distribution_major_version }}.noarch.rpm"
+            disable_gpg_check: true
+            state: present
+
+        - name: Install package 'epel-next-release'
+          ansible.builtin.yum:
+            name: "https://dl.fedoraproject.org/pub/epel/epel-next-release-latest-{{ ansible_distribution_major_version }}.noarch.rpm"
+            disable_gpg_check: true
+            state: present
+
+        - name: Install 'dnf config-manager'
+          shell: dnf -y install 'dnf-command(config-manager)'
+
+        - name: Enable repos of 'epel'
+          shell: dnf config-manager --enable epel epel-debuginfo epel-source
+
+        - name: Enable repos of 'epel-next'
+          shell: dnf config-manager --enable epel-next epel-next-debuginfo epel-next-source
+
+    # CentOS 7
+    - when: ansible_distribution == "CentOS" and ansible_distribution_major_version|int == 7
+      block:
+        - name: Install package 'epel-release'
+          ansible.builtin.yum:
+            name: epel-release
+            state: present
+
+        - name: Install package 'yum-utils'
+          ansible.builtin.yum:
+            name: yum-utils
+            state: present
+
+        - name: Enable repos of 'epel'
+          shell: yum-config-manager --enable epel epel-debuginfo epel-source
+
+    # CentOS Stream N where N >= 8
+    - when: ansible_distribution == "CentOS" and ansible_distribution_major_version|int >= 8
+      block:
+        - name: Install package 'epel-release'
+          ansible.builtin.yum:
+            name: epel-release
+            state: present
+
+        - name: Install package 'epel-next-release'
+          ansible.builtin.yum:
+            name: epel-next-release
+            state: present
+
+        - name: Install 'dnf config-manager'
+          shell: dnf -y install 'dnf-command(config-manager)'
+
+        - name: Enable repos of 'epel'
+          shell: dnf config-manager --enable epel epel-debuginfo epel-source
+
+        - name: Enable repos of 'epel-next'
+          shell: dnf config-manager --enable epel-next epel-next-debuginfo epel-next-source


Test patch:
diff --git a/tests/prepare/feature/data/.fmf/version b/tests/prepare/feature/data/.fmf/version
new file mode 100644
index 0000000000..d00491fd7e
--- /dev/null
+++ b/tests/prepare/feature/data/.fmf/version
@@ -0,0 +1,1 @@
+1
diff --git a/tests/prepare/feature/data/check-repos.sh b/tests/prepare/feature/data/check-repos.sh
new file mode 100755
index 0000000000..756f463325
--- /dev/null
+++ b/tests/prepare/feature/data/check-repos.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+set -ex
+
+pkgmgr=$(dnf --version > /dev/null 2>&1 && echo dnf || echo yum)
+
+STATE=${STATE:-"enabled"}
+REPOSITORIES=${REPOSITORIES:-"epel epel-debuginfo epel-source"}
+
+for reponame in $REPOSITORIES; do
+    if [[ "$STATE" == "enabled" ]]; then
+        $pkgmgr repoinfo "$reponame" | grep -E "^Repo-status[[:space:]]+: enabled"
+    else
+        # XXX: why do we check 'Total packages: 0' if it fails to check 'Repo-status'?
+        ($pkgmgr repoinfo "$reponame" | grep -E "^Repo-status[[:space:]]+: disabled") || \
+            ($pkgmgr repoinfo "$reponame" | grep -E '^Total packages: 0')
+    fi
+done
diff --git a/tests/prepare/feature/data/plans.fmf b/tests/prepare/feature/data/plans.fmf
new file mode 100644
index 0000000000..25b65ba7ff
--- /dev/null
+++ b/tests/prepare/feature/data/plans.fmf
@@ -0,0 +1,32 @@
+provision:
+    how: container
+
+execute:
+    how: tmt
+
+/epel:
+    discover:
+        how: shell
+        tests:
+          - name: test
+            test: ./check-repos.sh
+
+    prepare:
+        how: feature
+
+    environment:
+        REPOSITORIES: epel
+
+    /enabled:
+        prepare+:
+            epel: enabled
+
+        environment+:
+            STATE: enabled
+
+    /disabled:
+        prepare+:
+            epel: disabled
+
+        environment+:
+            STATE: disabled
diff --git a/tests/prepare/feature/main.fmf b/tests/prepare/feature/main.fmf
new file mode 100644
index 0000000000..2c4c4fa3f7
--- /dev/null
+++ b/tests/prepare/feature/main.fmf
@@ -0,0 +1,3 @@
+summary: Common feature to enable/disable EPEL
+description:
+    Make sure enable/disable EPEL works.
diff --git a/tests/prepare/feature/test.sh b/tests/prepare/feature/test.sh
new file mode 100755
index 0000000000..6d0c94a133
--- /dev/null
+++ b/tests/prepare/feature/test.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+. /usr/share/beakerlib/beakerlib.sh || exit 1
+
+rlJournalStart
+    rlPhaseStartSetup
+        rlRun "pushd data"
+    rlPhaseEnd
+
+    images="centos:stream9 centos:stream8 ubi9 ubi8"
+
+    # EPEL
+    for image in $images; do
+        rlPhaseStartTest "Enable EPEL on $image"
+            rlRun -s "tmt -vvv run -a plan --name '/epel/enabled' provision --how container --image $image"
+        rlPhaseEnd
+
+        rlPhaseStartTest "Disable EPEL on $image"
+            rlRun -s "tmt -vvv run -a plan --name '/epel/disabled' provision --how container --image $image"
+        rlPhaseEnd
+    done
+
+    rlPhaseStartCleanup
+        rlRun "popd"
+    rlPhaseEnd
+rlJournalEnd