Repository: cekit/cekit

Issue:
Support for `microdnf reinstall` command?
Hi all,
we would need to reinstall a package by using microdnf, so having the cekit generating a Dockerfile having something like:

```shell
microdnf reinstall ...
```

We use something like 

```yaml
packages:
  manager: microdnf
  install:
    - <package>
    - <package>
```

to get a `microdnf install` command.

Looking at the documentation https://docs.cekit.io/en/latest/descriptor/image.html#packages-to-install I don't see a way to do have a `reinstall` command.
Is there some option I am missing? Any workaround to this?


Discussion:

Patch:
diff --git a/cekit/descriptor/image.py b/cekit/descriptor/image.py
index 44ef2a3..5bba7bb 100644
--- a/cekit/descriptor/image.py
+++ b/cekit/descriptor/image.py
@@ -305,10 +305,19 @@ class Image(Descriptor):
             # Merge override osbs items into self.
             self.osbs = self.osbs.merge(override.osbs)
 
-            for package in override.packages.install:
+            # Using 'or []' to avoid having to set default value in packages.py for _descriptor["remove"]
+            for package in override.packages.remove or []:
+                if package not in self.packages.remove:
+                    self.packages.remove.append(package)
+
+            for package in override.packages.install or []:
                 if package not in self.packages.install:
                     self.packages.install.append(package)
 
+            for package in override.packages.reinstall or []:
+                if package not in self.packages.reinstall:
+                    self.packages.reinstall.append(package)
+
             artifact_overrides = self._image_overrides.artifacts
             image_artifacts = Image._to_dict(self.artifacts)
             for i, artifact in enumerate(override.artifacts):
diff --git a/cekit/descriptor/packages.py b/cekit/descriptor/packages.py
index 762154e..d93b23f 100644
--- a/cekit/descriptor/packages.py
+++ b/cekit/descriptor/packages.py
@@ -21,9 +21,15 @@ map:
   repositories:
     seq:
       - {type: any}
+  remove:
+    seq:
+      - {type: any}
   install:
     seq:
       - {type: any}
+  reinstall:
+    seq:
+      - {type: any}
   manager: {type: str, enum: ['yum', 'dnf', 'microdnf', 'apk', 'apt-get']}
   manager_flags: {type: str}"""
 )
@@ -101,9 +107,6 @@ class Packages(Descriptor):
         else:
             self._descriptor["repositories"] = [Repository(x) for x in repositories]
 
-        # TODO: This looks like a no-op?!
-        self._descriptor["install"] = self._descriptor.get("install", [])
-
     @property
     def manager(self):
         return self.get("manager")
@@ -120,6 +123,14 @@ class Packages(Descriptor):
     def install(self) -> List[str]:
         return self.get("install")
 
+    @property
+    def remove(self) -> List[str]:
+        return self.get("remove")
+
+    @property
+    def reinstall(self) -> List[str]:
+        return self.get("reinstall")
+
     @property
     def content_sets(self) -> ContentSetType:
         # TODO: content_sets seems to be undocumented?! Not sure what this is.
diff --git a/cekit/template_helper.py b/cekit/template_helper.py
index 40cb789..d5bc096 100644
--- a/cekit/template_helper.py
+++ b/cekit/template_helper.py
@@ -27,6 +27,36 @@ class TemplateHelper(object):
 
         return packages
 
+    def packages_to_reinstall(self, image):
+        """
+        Method that returns list of packages to be reinstalled by any of
+        modules or directly in the image
+        """
+        all_modules = self.modules(image)
+
+        packages = []
+
+        for module in all_modules:
+            if "packages" in module and "reinstall" in module.packages:
+                packages += module.packages.reinstall
+
+        return packages
+
+    def packages_to_remove(self, image):
+        """
+        Method that returns list of packages to be removed by any of
+        modules or directly in the image
+        """
+        all_modules = self.modules(image)
+
+        packages = []
+
+        for module in all_modules:
+            if "packages" in module and "remove" in module.packages:
+                packages += module.packages.remove
+
+        return packages
+
     def modules(self, image):
         all_modules = []
 
@@ -131,6 +161,14 @@ class TemplateHelper(object):
         else:
             return "install -y"
 
+    def package_manager_reinstall(self, pkg_mgr):
+        if "apk" in pkg_mgr:
+            return "fix --reinstall"
+        elif "apt-get" in pkg_mgr:
+            return "--reinstall install -y"
+        else:
+            return "reinstall -y"
+
     def package_manager_query(self, pkg_mgr):
         if "apk" in pkg_mgr:
             return "apk info -e"
@@ -138,3 +176,9 @@ class TemplateHelper(object):
             return "dpkg-query --list"
         else:
             return "rpm -q"
+
+    def package_manager_remove(self, pkg_mgr):
+        if "apk" in pkg_mgr:
+            return "del"
+        else:
+            return "remove -y"
diff --git a/cekit/templates/template.jinja b/cekit/templates/template.jinja
index 0d6e18d..03973c0 100644
--- a/cekit/templates/template.jinja
+++ b/cekit/templates/template.jinja
@@ -34,6 +34,15 @@ rm{% for repo in repositories %} /etc/yum.repos.d/{{ repo.filename }}{% endfor %
     {% endif %}
 {%- endmacro -%}
 
+{#
+ # Removes packages using specified package manager.
+ #}
+{%- macro pkg_remove(pkg_manager, pkg_manager_flags, packages) -%}
+    {% if pkg_manager in helper.SUPPORTED_PACKAGE_MANAGERS %}
+{{ pkg_manager }} {{ helper.package_manager_flags(pkg_manager, pkg_manager_flags) }} {{ helper.package_manager_remove(pkg_manager) }} {%- for package in packages %} {{ package }}{% endfor %}
+    {% endif %}
+{%- endmacro -%}
+
 {#
  # Installs packages using specified package manager.
  #}
@@ -47,6 +56,19 @@ rm{% for repo in repositories %} /etc/yum.repos.d/{{ repo.filename }}{% endfor %
     {% endif %}
 {%- endmacro -%}
 
+{#
+ # Reinstalls packages using specified package manager.
+ #}
+{%- macro pkg_reinstall(pkg_manager, pkg_manager_flags, packages) -%}
+    {% if pkg_manager in helper.SUPPORTED_PACKAGE_MANAGERS %}
+{{ pkg_manager }} {{ helper.package_manager_flags(pkg_manager, pkg_manager_flags) }} {{ helper.package_manager_reinstall(pkg_manager) }} {%- for package in packages %} {{ package }}{% endfor %} \
+    {% if pkg_manager == 'microdnf' %}
+            && {{ pkg_manager }} clean all \
+    {% endif %}
+            && {{ helper.package_manager_query(pkg_manager) }}{% for package in packages %} {{ package.split('=')[0] }}{% endfor %}
+    {% endif %}
+{%- endmacro -%}
+
 {#
  # Clears repository cache using the specified package manager.
  #}
@@ -102,12 +124,22 @@ rm{% for repo in repositories %} /etc/yum.repos.d/{{ repo.filename }}{% endfor %
         {% endif -%}
         {% endif -%}
 
-        {% if module.packages and module.packages.install %}
-        # Switch to 'root' user to install '{{ module.name }}' {{ module_type }} defined packages
+        {% if module.packages and module.packages.remove or module.packages.install or module.packages.reinstall %}
+        # Switch to 'root' user for package management for '{{ module.name }}' {{ module_type }} defined packages
         USER root
+        {% if module.packages and module.packages.remove %}
+        # Remove packages defined in the '{{ module.name }}' {{ module_type }}
+        RUN {{ pkg_remove(parent_image.packages.manager, parent_image.packages.manager_flags, module.packages.remove) }}
+        {% endif -%}
+        {% if module.packages and module.packages.install %}
         # Install packages defined in the '{{ module.name }}' {{ module_type }}
         RUN {{ pkg_install(parent_image.packages.manager, parent_image.packages.manager_flags, module.packages.install) }}
         {% endif -%}
+        {% if module.packages and module.packages.reinstall %}
+        # Reinstall packages defined in the '{{ module.name }}' {{ module_type }}
+        RUN {{ pkg_reinstall(parent_image.packages.manager, parent_image.packages.manager_flags, module.packages.reinstall) }}
+        {% endif -%}
+        {% endif -%}
 
         {% if module.envs|selectattr("value")|list|length > 0 %}
         # Set '{{ module.name }}' {{ module_type }} defined environment variables
@@ -225,7 +257,8 @@ rm{% for repo in repositories %} /etc/yum.repos.d/{{ repo.filename }}{% endfor %
     RUN [ ! -d /tmp/artifacts ] || rm -rf /tmp/artifacts
     {% endif -%}
 
-    {% if helper.packages_to_install(image) and image.packages.manager in ['yum', 'dnf', 'microdnf'] %}
+    {% if helper.packages_to_install(image) or helper.packages_to_remove(image) or
+            helper.packages_to_reinstall(image) and image.packages.manager in ['yum', 'dnf', 'microdnf'] %}
     # Clear package manager metadata
     RUN {{ repo_clear_cache(image.packages.manager) }}
     {% endif -%}
diff --git a/docs/conf.py b/docs/conf.py
index 9db4f3e..b16bbcf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -46,7 +46,7 @@ version = ".".join(cekit_version.split(".")[0:2])
 # The full version, including alpha/beta/rc tags.
 release = cekit_version
 
-language = None
+language = "en"
 
 exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "venv"]
 
diff --git a/docs/descriptor/includes/image/packages.rst b/docs/descriptor/includes/image/packages.rst
index a6b99b3..5d3d976 100644
--- a/docs/descriptor/includes/image/packages.rst
+++ b/docs/descriptor/includes/image/packages.rst
@@ -1,5 +1,7 @@
 .. include:: includes/packages/common.rst
 .. include:: includes/packages/install.rst
+.. include:: includes/packages/reinstall.rst
+.. include:: includes/packages/remove.rst
 .. include:: includes/packages/manager.rst
 .. include:: includes/packages/repositories/common.rst
 .. include:: includes/packages/repositories/plain.rst
diff --git a/docs/descriptor/includes/module/packages.rst b/docs/descriptor/includes/module/packages.rst
index 141ed88..a7949df 100644
--- a/docs/descriptor/includes/module/packages.rst
+++ b/docs/descriptor/includes/module/packages.rst
@@ -1,5 +1,7 @@
 .. include:: includes/packages/common.rst
 .. include:: includes/packages/install.rst
+.. include:: includes/packages/reinstall.rst
+.. include:: includes/packages/remove.rst
 .. include:: includes/packages/manager.rst
 .. include:: includes/packages/repositories/common.rst
 .. include:: includes/packages/repositories/plain.rst
diff --git a/docs/descriptor/includes/packages/common.rst b/docs/descriptor/includes/packages/common.rst
index 3403dc3..bb70202 100644
--- a/docs/descriptor/includes/packages/common.rst
+++ b/docs/descriptor/includes/packages/common.rst
@@ -7,9 +7,11 @@ Required
     No
 
 
-To install additional packages you can use the ``packages``
+To install, reinstall or remove additional packages you can use the ``packages``
 section where you specify package names and repositories to be used, as well
-as the package manager that is used to manage packages in this image.
+as the package manager that is used to manage packages in this image. Note that
+in the generated image removal is performed *before* installation and reinstall is
+*after* installation.
 
 .. code-block:: yaml
     :caption: Example package section for RPM-based distro
@@ -20,12 +22,16 @@ as the package manager that is used to manage packages in this image.
                 id: rhel7-extras-rpm
         manager: dnf
         manager_flags:
+        remove:
+            - dummy-package
         install:
             - mongodb24-mongo-java-driver
             - postgresql-jdbc
             - mysql-connector-java
             - maven
             - hostname
+        reinstall:
+            - tzdata
 
 .. code-block:: yaml
     :caption: Example package section for Alpine Linux
diff --git a/docs/descriptor/includes/packages/reinstall.rst b/docs/descriptor/includes/packages/reinstall.rst
new file mode 100644
index 0000000..bbf618f
--- /dev/null
+++ b/docs/descriptor/includes/packages/reinstall.rst
@@ -0,0 +1,15 @@
+Packages to reinstall
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Key
+    ``reinstall``
+Required
+    No
+
+Packages listed in the ``reinstall`` section are marked to be reinstalled in the container image.
+
+.. code-block:: yaml
+
+    packages:
+        reinstall:
+            - tzdata
diff --git a/docs/descriptor/includes/packages/remove.rst b/docs/descriptor/includes/packages/remove.rst
new file mode 100644
index 0000000..5d818b7
--- /dev/null
+++ b/docs/descriptor/includes/packages/remove.rst
@@ -0,0 +1,15 @@
+Packages to remove
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Key
+    ``remove``
+Required
+    No
+
+Packages listed in the ``remove`` section are marked to be removed from the container image.
+
+.. code-block:: yaml
+
+    packages:
+        remove:
+            - postgresql-jdbc
diff --git a/docs/descriptor/includes/packages/repositories.rst b/docs/descriptor/includes/packages/repositories.rst
deleted file mode 100644
index a5ad6a0..0000000
--- a/docs/descriptor/includes/packages/repositories.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-.. include:: includes/packages/repositories/common.rst
-.. include:: includes/packages/repositories/plain.rst
-.. include:: includes/packages/repositories/rpm.rst
-.. include:: includes/packages/repositories/url.rst
diff --git a/docs/getting-started/modules.rst b/docs/getting-started/modules.rst
index bccf8d3..6aa5282 100644
--- a/docs/getting-started/modules.rst
+++ b/docs/getting-started/modules.rst
@@ -90,7 +90,7 @@ JDK8
          - java-1.8.0-openjdk-devel
 
 * An :doc:`environment variable</descriptor/includes/envs>` has been defined that will be present in the container after running the image.
-* :doc:`packages</descriptor/includes/packages>` have been used to add the JDK RPM.
+* :doc:`packages</descriptor/includes/image/packages>` have been used to add the JDK RPM.
 
 
 User
diff --git a/docs/handbook/modules/image_order.rst b/docs/handbook/modules/image_order.rst
index cf85cf7..80d382a 100644
--- a/docs/handbook/modules/image_order.rst
+++ b/docs/handbook/modules/image_order.rst
@@ -1,5 +1,5 @@
 Image Descriptor and Modules
-=========================
+============================
 
 .. contents::
     :backlinks: none


Test patch:
diff --git a/tests/test_dockerfile.py b/tests/test_dockerfile.py
index 22e1f72..0d32b5e 100644
--- a/tests/test_dockerfile.py
+++ b/tests/test_dockerfile.py
@@ -704,6 +704,78 @@ def test_overrides_applied_to_all_multi_stage_images(tmpdir):
     regex_dockerfile(target, "^###### END image 'targetimage:SNAPSHOT'$")
 
 
+# @pytest.mark.parametrize("manager", ["yum", "dnf", "microdnf"])
+def test_package_removal_and_install_and_reinstall(tmpdir):
+    target = str(tmpdir.mkdir("target"))
+
+    generate(
+        target,
+        ["-v", "build", "--dry-run", "--container-file", "Dockerfile", "podman"],
+        descriptor={
+            "packages": {
+                "manager": "dnf",
+                "repositories": [{"name": "foo", "rpm": "foo-repo.rpm"}],
+                "install": ["a"],
+                "remove": ["b"],
+                "reinstall": ["t"],
+            },
+            "osbs": {"repository": {"name": "repo_name", "branch": "branch_name"}},
+        },
+    )
+    regex_dockerfile(target, "remove -y b")
+    with open(os.path.join(target, "target", "image", "Dockerfile"), "r") as _file:
+        dockerfile = _file.read()
+    assert (
+        """# Switch to 'root' user for package management for 'testimage' image defined packages
+        USER root
+        # Remove packages defined in the 'testimage' image
+        RUN dnf --setopt=tsflags=nodocs remove -y b
+        # Install packages defined in the 'testimage' image
+        RUN dnf --setopt=tsflags=nodocs install -y a \\
+            && rpm -q a
+        # Reinstall packages defined in the 'testimage' image
+        RUN dnf --setopt=tsflags=nodocs reinstall -y t \\
+            && rpm -q t"""
+        in dockerfile
+    )
+
+
+def test_package_removal_without_install(tmpdir):
+    target = str(tmpdir.mkdir("target"))
+
+    generate(
+        target,
+        ["-v", "build", "--dry-run", "--container-file", "Dockerfile", "podman"],
+        descriptor={
+            "packages": {
+                "manager": "dnf",
+                "repositories": [{"name": "foo", "rpm": "foo-repo.rpm"}],
+                "remove": ["b"],
+            },
+            "osbs": {"repository": {"name": "repo_name", "branch": "branch_name"}},
+        },
+    )
+    regex_dockerfile(target, "remove -y b")
+
+
+def test_package_reinstall(tmpdir):
+    target = str(tmpdir.mkdir("target"))
+
+    generate(
+        target,
+        ["-v", "build", "--dry-run", "--container-file", "Dockerfile", "podman"],
+        descriptor={
+            "packages": {
+                "manager": "dnf",
+                "repositories": [{"name": "foo", "rpm": "foo-repo.rpm"}],
+                "reinstall": ["tzdata"],
+            },
+            "osbs": {"repository": {"name": "repo_name", "branch": "branch_name"}},
+        },
+    )
+    regex_dockerfile(target, "reinstall -y tzdata")
+
+
 def generate(image_dir, command, descriptor=None, exit_code=0):
     desc = basic_config.copy()
 
diff --git a/tests/test_validate.py b/tests/test_validate.py
index 425f28c..218fbae 100644
--- a/tests/test_validate.py
+++ b/tests/test_validate.py
@@ -1306,7 +1306,7 @@ def test_package_related_commands_packages_in_module(tmpdir, mocker):
     expected_packages_order_install = """
 ###### START module 'packages_module:1.0'
 ###### \\
-        # Switch to 'root' user to install 'packages_module' module defined packages
+        # Switch to 'root' user for package management for 'packages_module' module defined packages
         USER root
         # Install packages defined in the 'packages_module' module
         RUN yum --setopt=tsflags=nodocs install -y kernel java-1.8.0-openjdk \\
@@ -1316,7 +1316,7 @@ def test_package_related_commands_packages_in_module(tmpdir, mocker):
 
 ###### START module 'packages_module_1:1.0'
 ###### \\
-        # Switch to 'root' user to install 'packages_module_1' module defined packages
+        # Switch to 'root' user for package management for 'packages_module_1' module defined packages
         USER root
         # Install packages defined in the 'packages_module_1' module
         RUN yum --setopt=tsflags=nodocs install -y wget mc \\
@@ -1345,7 +1345,6 @@ def test_package_related_commands_packages_in_image(tmpdir, mocker):
     run_cekit(image_dir)
 
     expected_packages_install = """
-        # Switch to 'root' user to install 'test/image' image defined packages
         USER root
         # Install packages defined in the 'test/image' image
         RUN yum --setopt=tsflags=nodocs install -y wget mc \\