From 891bdaa1ae57f859995f5f8de346cb059a923839 Mon Sep 17 00:00:00 2001 From: Itxaka Date: Thu, 5 Dec 2024 15:22:26 +0100 Subject: [PATCH] Ad install plugin (#189) Signed-off-by: Itxaka --- pkg/executor/executor.go | 1 + pkg/plugins/packages.go | 164 +++++++++++++++++++++++++++++++++++ pkg/plugins/packages_test.go | 114 ++++++++++++++++++++++++ pkg/schema/schema.go | 7 ++ 4 files changed, 286 insertions(+) create mode 100644 pkg/plugins/packages.go create mode 100644 pkg/plugins/packages_test.go diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index cc4d214b..9e2e1893 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -91,6 +91,7 @@ func NewExecutor(opts ...Options) Executor { plugins.SystemdFirstboot, plugins.DataSources, plugins.Layout, + plugins.Packages, }, } diff --git a/pkg/plugins/packages.go b/pkg/plugins/packages.go new file mode 100644 index 00000000..1de3840b --- /dev/null +++ b/pkg/plugins/packages.go @@ -0,0 +1,164 @@ +package plugins + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + + "github.com/joho/godotenv" + "github.com/mudler/yip/pkg/logger" + "github.com/mudler/yip/pkg/schema" + "github.com/twpayne/go-vfs/v4" +) + +type Installer string + +const ( + APTInstaller Installer = "apt-get" + DNFInstaller Installer = "dnf" + PacmanInstaller Installer = "pacman" + SUSEInstaller Installer = "zypper" + AlpineInstaller Installer = "apk" + UnknownInstaller Installer = "unknown" +) + +func (d Installer) String() string { + return string(d) +} + +type Distro string + +const ( + Debian Distro = "debian" + Ubuntu Distro = "ubuntu" + RedHat Distro = "redhat" + CentOS Distro = "centos" + RockyLinux Distro = "rocky" + AlmaLinux Distro = "almalinux" + Fedora Distro = "fedora" + Arch Distro = "arch" + Alpine Distro = "alpine" + OpenSUSELeap Distro = "opensuse-leap" + OpenSUSETumbleweed Distro = "opensuse-tumbleweed" +) + +// Packages runs the package manager to try to install/remove/refresh packages +// It will try to identify the package manager based on the distro +// If it can't identify the package manager, it will return an error +// Order is Refresh -> Install -> Remove +func Packages(l logger.Interface, s schema.Stage, fs vfs.FS, console Console) error { + // Don't do anything if empty + if len(s.Packages.Remove) == 0 && len(s.Packages.Install) == 0 && s.Packages.Refresh == false { + return nil + } + + var installArgs, updateArgs, removeArgs []string + + cmd := identifyInstaller(fs) + + switch cmd { + case APTInstaller: + // Needed so it doesn't ask for user input + _ = os.Setenv("DEBIAN_FRONTEND", "noninteractive") + defer func() { + _ = os.Unsetenv("DEBIAN_FRONTEND") + }() + updateArgs = []string{"-y", "update"} + installArgs = []string{"-y", "--no-install-recommends", "install"} + removeArgs = []string{"-y", "remove"} + case AlpineInstaller: + updateArgs = []string{"update"} + installArgs = []string{"add", "--no-cache"} + removeArgs = []string{"del", "--no-cache"} + case DNFInstaller, SUSEInstaller: + updateArgs = []string{"-y", "update"} + installArgs = []string{"-y", "install"} + removeArgs = []string{"-y", "remove"} + case PacmanInstaller: + updateArgs = []string{"-Sy"} + installArgs = []string{"-S", "--noconfirm"} + removeArgs = []string{"-R", "--noconfirm"} + default: + l.Errorf("Unknown installer") + return errors.New("unknown package manager") + } + // Run update + if s.Packages.Refresh { + l.Debugf("Running update") + out, err := console.Run(templateSysData(l, strings.Join(append([]string{cmd.String()}, updateArgs...), " "))) + if err != nil { + return err + } + if strings.TrimSpace(out) != "" { + l.Debug(fmt.Sprintf("Command output: %s", out)) + } else { + l.Debugf("Empty command output") + } + } + + if s.Packages.Install != nil { + // Run install + installArgs = append(installArgs, s.Packages.Install...) + l.Debugf("Running install") + out, err := console.Run(templateSysData(l, strings.Join(append([]string{cmd.String()}, installArgs...), " "))) + if err != nil { + return err + } + if strings.TrimSpace(out) != "" { + l.Debug(fmt.Sprintf("Command output: %s", out)) + } else { + l.Debugf("Empty command output") + } + } + + if s.Packages.Remove != nil { + // Run remove + removeArgs = append(removeArgs, s.Packages.Remove...) + l.Debugf("Running remove") + out, err := console.Run(templateSysData(l, strings.Join(append([]string{cmd.String()}, removeArgs...), " "))) + if err != nil { + return err + } + if strings.TrimSpace(out) != "" { + l.Debug(fmt.Sprintf("Command output: %s", out)) + } else { + l.Debugf("Empty command output") + } + } + + return nil +} + +// identifyInstaller returns the package manager based on the distro +func identifyInstaller(fsys vfs.FS) Installer { + file, err := fsys.Open("/etc/os-release") + if err != nil { + return UnknownInstaller + } + defer func(file fs.File) { + err := file.Close() + if err != nil { + + } + }(file) + val, err := godotenv.Parse(file) + if err != nil { + return UnknownInstaller + } + switch Distro(val["ID"]) { + case Debian, Ubuntu: + return APTInstaller + case Fedora, RockyLinux, AlmaLinux, RedHat, CentOS: + return DNFInstaller + case Arch: + return PacmanInstaller + case Alpine: + return AlpineInstaller + case OpenSUSELeap, OpenSUSETumbleweed: + return SUSEInstaller + default: + return UnknownInstaller + } +} diff --git a/pkg/plugins/packages_test.go b/pkg/plugins/packages_test.go new file mode 100644 index 00000000..4a9eb880 --- /dev/null +++ b/pkg/plugins/packages_test.go @@ -0,0 +1,114 @@ +package plugins + +import ( + "github.com/mudler/yip/pkg/schema" + consoletests "github.com/mudler/yip/tests/console" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sanity-io/litter" + "github.com/sirupsen/logrus" + "github.com/twpayne/go-vfs/v4/vfst" + "io" +) + +var _ = Describe("Commands", func() { + Context("parsing yip file", func() { + testConsole := consoletests.TestConsole{} + l := logrus.New() + l.SetOutput(io.Discard) + + BeforeEach(func() { + consoletests.Reset() + }) + It("execute proper install commands", func() { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{}) + Expect(err).Should(BeNil()) + defer cleanup() + Expect(fs.Mkdir("/etc", 0755)).ToNot(HaveOccurred()) + Expect(fs.WriteFile("/etc/os-release", []byte("ID=debian\nVERSION=10\n"), 0644)).ToNot(HaveOccurred()) + + err = Packages(l, schema.Stage{ + Packages: schema.Packages{ + Install: []string{"foo", "bar"}, + Remove: []string{"baz", "qux"}, + Refresh: true, + }, + }, fs, testConsole) + Expect(err).ShouldNot(HaveOccurred()) + Expect(consoletests.Commands).Should(Equal([]string{"apt-get -y update", "apt-get -y --no-install-recommends install foo bar", "apt-get -y remove baz qux"})) + }) + It("execute proper install commands for different OS", func() { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{}) + Expect(err).Should(BeNil()) + defer cleanup() + Expect(fs.Mkdir("/etc", 0755)).ToNot(HaveOccurred()) + stage := schema.Stage{ + Packages: schema.Packages{ + Install: []string{"foo", "bar"}, + Remove: []string{"baz", "qux"}, + Refresh: true, + }, + } + type test struct { + osRelease string + expected []string + } + tests := []test{ + { + osRelease: "ID=debian\nVERSION=10\n", + expected: []string{"apt-get -y update", "apt-get -y --no-install-recommends install foo bar", "apt-get -y remove baz qux"}, + }, + { + osRelease: "ID=debian\nVERSION=11\n", + expected: []string{"apt-get -y update", "apt-get -y --no-install-recommends install foo bar", "apt-get -y remove baz qux"}, + }, + { + osRelease: "ID=ubuntu\nVERSION=20.04\n", + expected: []string{"apt-get -y update", "apt-get -y --no-install-recommends install foo bar", "apt-get -y remove baz qux"}, + }, + { + osRelease: "ID=centos\nVERSION=8\n", + expected: []string{"dnf -y update", "dnf -y install foo bar", "dnf -y remove baz qux"}, + }, + { + osRelease: "ID=fedora\nVERSION=34\n", + expected: []string{"dnf -y update", "dnf -y install foo bar", "dnf -y remove baz qux"}, + }, + { + osRelease: "ID=alpine\nVERSION=3.14\n", + expected: []string{"apk update", "apk add --no-cache foo bar", "apk del --no-cache baz qux"}, + }, + { + osRelease: "ID=opensuse-leap\nVERSION=15.3\n", + expected: []string{"zypper -y update", "zypper -y install foo bar", "zypper -y remove baz qux"}, + }, + { + osRelease: "ID=arch\nVERSION=rolling\n", + expected: []string{"pacman -Sy", "pacman -S --noconfirm foo bar", "pacman -R --noconfirm baz qux"}, + }, + } + + for _, t := range tests { + Expect(fs.WriteFile("/etc/os-release", []byte(t.osRelease), 0644)).ToNot(HaveOccurred()) + err = Packages(l, stage, fs, testConsole) + Expect(err).ShouldNot(HaveOccurred(), t.osRelease) + Expect(consoletests.Commands).Should(Equal(t.expected), litter.Sdump(t.osRelease)) + consoletests.Reset() + } + }) + It("fails if it cant identify the systems package manager", func() { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{}) + Expect(err).Should(BeNil()) + defer cleanup() + err = Packages(l, schema.Stage{ + Packages: schema.Packages{ + Install: []string{"foo", "bar"}, + Remove: []string{"baz", "qux"}, + Refresh: true, + }, + }, fs, testConsole) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown package manager")) + }) + }) +}) diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 763b58da..19ebc6fb 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -152,6 +152,7 @@ type Stage struct { Systemctl Systemctl `yaml:"systemctl,omitempty"` Environment map[string]string `yaml:"environment,omitempty"` EnvironmentFile string `yaml:"environment_file,omitempty"` + Packages Packages `yaml:"packages,omitempty"` After []Dependency `yaml:"after,omitempty"` @@ -171,6 +172,12 @@ type Systemctl struct { Mask []string `yaml:"mask,omitempty"` } +type Packages struct { + Install []string `yaml:"install,omitempty"` + Remove []string `yaml:"remove,omitempty"` + Refresh bool `yaml:"refresh,omitempty"` +} + type DNS struct { Nameservers []string `yaml:"nameservers,omitempty"` DnsSearch []string `yaml:"search,omitempty"`