Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add bootc installation method4 #3586

Draft
wants to merge 1 commit into
base: package-manager-engines
Choose a base branch
from

Conversation

ukulekek
Copy link
Collaborator

@ukulekek ukulekek commented Mar 7, 2025

Pull Request Checklist

  • implement the feature
  • write the documentation
  • extend the test coverage
  • update the specification
  • adjust plugin docstring
  • modify the json schema
  • mention the version
  • include a release note

@ukulekek
Copy link
Collaborator Author

ukulekek commented Mar 7, 2025

Hi, @happz. I wanted to ask what the relationship is between Install* classes and corresponding package managers? Respectively, what logic should I move to the package manager out of the InstallBootc class?

@happz
Copy link
Collaborator

happz commented Mar 7, 2025

Hi, @happz. I wanted to ask what the relationship is between Install* classes and corresponding package managers? Respectively, what logic should I move to the package manager out of the InstallBootc class?

Ideally, all of it. Package managers govern how packages are installed, providing the primitives. Constructing commands in install_from_repository would lead to duplication.

The issue is, they do run their commands immediately. How about we add a middleman, an "adapter" class, which would take command from a package manager, and do something with it? The default one, the current behavior, would be to take that command and run it on the guest, but your InstallBootc can have its own custom adapter which would just collect the commands rather than running them.

TL;DR: we want those commands, we just don't want them to run on the guest now. We need to tell package managers not to run them, instead we need to collect them and add them to the containerfile. Eventually, your InstallBootc should be pretty much the same as any other installation plugin, i.e. its interaction with a package manager in install_from_repository shouldn't be much more beyond the following:

        self.guest.package_manager.install(
            *self.list_installables("package", *self.packages),
            options=Options(
                excluded_packages=self.exclude,
                skip_missing=self.skip_missing,
            ),
        )

And the output would not be CommandOutput, but list[ShellScript], and you would take them and put them into containerfile.

I was thinking about something like this but I didn't have a chance to do it properly (or everywhere), and there are some typing issues that need to be sorted out, plus actions that run more than one command (if there are any, and there might be, in apt or apk). Also, the adapter may need more than one type variable, the one represents output of "perform the command", but there is also check_presence returning a mapping of present packages - instead, we want it to return the command, so that's one to parameterize as well. Let me know what you think, and I can give it a few runs over the weekend.

diff --git a/tmt/package_managers/__init__.py b/tmt/package_managers/__init__.py
index 829e33aa..f17ca6d8 100644
--- a/tmt/package_managers/__init__.py
+++ b/tmt/package_managers/__init__.py
@@ -1,13 +1,13 @@
 import shlex
 from collections.abc import Iterator
-from typing import TYPE_CHECKING, Any, Callable, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Optional, Union, TypeVar, Generic

 import tmt
 import tmt.log
 import tmt.plugins
 import tmt.utils
 from tmt.container import container, simple_field
-from tmt.utils import Command, CommandOutput, Path
+from tmt.utils import Command, CommandOutput, Path, ShellScript

 if TYPE_CHECKING:
     from tmt._compat.typing import TypeAlias
@@ -16,6 +16,8 @@ if TYPE_CHECKING:
     #: A type of package manager names.
     GuestPackageManager: TypeAlias = str

+T = TypeVar('T')
+

 #
 # Installable objects
@@ -148,6 +150,29 @@ class Options:
     allow_untrusted: bool = False


+class CommandAdapter(Generic[T]):
+    def on_command(self, script: ShellScript) -> T:
+        raise NotImplementedError
+
+
+class RunImmediatelyAdapter(CommandAdapter[CommandOutput]):
+    def __init__(self, guest: 'Guest', logger: tmt.log.Logger) -> None:
+        self.logger = logger
+        self.guest = guest
+
+    def on_command(self, script):
+        return self.guest.execute(script)
+
+
+class CollectAdapter(CommandAdapter[ShellScript]):
+    def __init__(self, guest: 'Guest', logger: tmt.log.Logger) -> None:
+        self.logger = logger
+        self.guest = guest
+
+    def on_command(self, script):
+        return script
+
+
 class PackageManager(tmt.utils.Common):
     """
     A base class for package manager plugins
@@ -169,12 +194,16 @@ class PackageManager(tmt.utils.Common):
     command: Command
     options: Command

-    def __init__(self, *, guest: 'Guest', logger: tmt.log.Logger) -> None:
+    adapter: CommandAdapter[T]
+
+    def __init__(self, *, guest: 'Guest', adapter: CommandAdapter[T], logger: tmt.log.Logger) -> None:
         super().__init__(logger=logger)

         self.guest = guest
         self.command, self.options = self.prepare_command()

+        self.adapter = adapter or RunImmediatelyAdapter(guest, logger)
+
     def prepare_command(self) -> tuple[Command, Command]:
         """
         Prepare installation command and subcommand options
@@ -193,22 +222,22 @@ class PackageManager(tmt.utils.Common):
         self,
         *installables: Installable,
         options: Optional[Options] = None,
-    ) -> CommandOutput:
+    ) -> T:
         raise NotImplementedError

     def reinstall(
         self,
         *installables: Installable,
         options: Optional[Options] = None,
-    ) -> CommandOutput:
+    ) -> T:
         raise NotImplementedError

     def install_debuginfo(
         self,
         *installables: Installable,
         options: Optional[Options] = None,
-    ) -> CommandOutput:
+    ) -> T:
         raise NotImplementedError

-    def refresh_metadata(self) -> CommandOutput:
+    def refresh_metadata(self) -> T:
         raise NotImplementedError
diff --git a/tmt/package_managers/dnf.py b/tmt/package_managers/dnf.py
index cb7612f2..bc113985 100644
--- a/tmt/package_managers/dnf.py
+++ b/tmt/package_managers/dnf.py
@@ -160,14 +160,14 @@ class Dnf(tmt.package_managers.PackageManager):
             f'{self.command.to_script()} makecache {self.options.to_script()} --refresh'
         )

-        return self.guest.execute(script)
+        return self.adapter.on_command(script)

     def install(
         self,
         *installables: Installable,
         options: Optional[Options] = None,
     ) -> CommandOutput:
-        return self.guest.execute(self._construct_install_script(*installables, options=options))
+        return self.adapter.on_command(self._construct_install_script(*installables, options=options))

     def reinstall(
         self,
diff --git a/tmt/steps/provision/__init__.py b/tmt/steps/provision/__init__.py
index 6bc6e60a..692335f6 100644
--- a/tmt/steps/provision/__init__.py
+++ b/tmt/steps/provision/__init__.py
@@ -1095,7 +1095,7 @@ class Guest(tmt.utils.Common):
             )

         return tmt.package_managers.find_package_manager(self.facts.package_manager)(
-            guest=self, logger=self._logger
+            guest=self, logger=self._logger, adapter=RunImmediatelyAdapter(guest, logger)
         )

     @functools.cached_property

@ukulekek
Copy link
Collaborator Author

ukulekek commented Mar 7, 2025

@happz, the idea with adapters sounds great to me as I was struggling to come up with a solution on how not to execute commands immediately. I'll try to move all logic to the package manager next week, thank you!

@happz
Copy link
Collaborator

happz commented Mar 8, 2025

@happz, the idea with adapters sounds great to me as I was struggling to come up with a solution on how not to execute commands immediately.

Sounds good, but it didn't work. I'm not smart enough to appease all type-related issues I was hitting. So, I took different approach: #3587 - not tested, just locally...

Package manager we have are now split into two parts: "engine" which does what PM does today, prepares commands to install stuff, but it does not run anything - instead, it returns these commands. The rest of code then works with "package manager" part, that is just a "call the engine and run what it gives you" frontend for its engine. tmt still uses this interface, calls guest.package_manager.install(...) which turns out to be self.guest.execute(self.engine.install(...)).

And for your use case, I imagined your code could take the engine itself, e.g. tmt.package_manager.dnf.DnfEngine, and then use its methods, e.g. install(...). It has the very same input as package manager's install, but it returns a shell script instead of running it. I imagine you would use the engine and its methods in the same way Install* does, except you would take their return values - scripts like dnf -y install foo bar baz - and inject them into Containerfiles you are building.

Something like this:

    # Let's say we collect instructions for the final Containerfile in self._containerfile_directives, and that we have self.engine which is the DnfEngine

    def install_from_repository(self) -> None:
        script = self.engine.install(
            *self.list_installables("package", *self.packages),
            options=Options(
                excluded_packages=self.exclude,
                skip_missing=self.skip_missing,
            ),
        )

        self._containerfile_directives.append(f'RUN {script}')

Feel free to play with it, I didn't experiment with your patch yet.

Also, all those \ns throughout the code are very hard to keep up with. Check out what install() and _install() do, they call sequence of methods of the selected installer - if I would working on this, I would take advantage of this: have a list of strings to collect Containerfile commands, populate it with some FROM ... and ENV ... directives as necessary, and then methods like install_local() or install_debuginfo() would talk to the engine (can it always be dnf? or do we need to detect it somehow?), and slap RUN ... before all scripts returned by the engine. Eventually, you'd have a list of strings, it should be enough to do some containerfile = '\n'.join(self._containerfile_directives), save it into a file and let podman build do its job.

Plus, you will not need an extra flag for tracking whether there are any commands to include - if self._containerfile_directives has just one item, it's your initial FROM ... and there is nothing else to do.

Let me know about your progress or any idea you get about the solution.

@ukulekek ukulekek changed the base branch from main to package-manager-engines March 10, 2025 11:55
@ukulekek ukulekek force-pushed the install-bootc branch 2 times, most recently from e1f91a4 to 5a22cc5 Compare March 11, 2025 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants