diff --git a/pkg/package_manager/apt.go b/pkg/package_manager/apt.go index eb9a3ff..49944ea 100644 --- a/pkg/package_manager/apt.go +++ b/pkg/package_manager/apt.go @@ -4,21 +4,15 @@ import ( "bufio" "bytes" "context" - "fmt" "log" "os" "os/exec" "strconv" "strings" - "text/template" - contextInternal "github.com/gameap/gameapctl/internal/context" osinfo "github.com/gameap/gameapctl/pkg/os_info" - "github.com/gameap/gameapctl/pkg/oscore" - pmapt "github.com/gameap/gameapctl/pkg/package_manager/apt" - "github.com/gameap/gameapctl/pkg/utils" + "github.com/gameap/gameapctl/pkg/package_manager/pkgconfig" "github.com/pkg/errors" - "github.com/samber/lo" ) const ( @@ -32,7 +26,7 @@ type apt struct{} // Search list packages available in the system that match the search // pattern. func (apt *apt) Search(_ context.Context, packName string) ([]PackageInfo, error) { - search, err := pmapt.Search(packName) + search, err := aptListSearch(packName) if err != nil { return nil, errors.WithMessage(err, "failed to search package") } @@ -93,6 +87,7 @@ func parseAPTCacheShowOutput(out []byte) []PackageInfo { scanner := bufio.NewScanner(bytes.NewReader(out)) var packageInfos []PackageInfo + var currentPackage *PackageInfo for scanner.Scan() { parts := strings.SplitN(scanner.Text(), ":", 2) @@ -100,32 +95,47 @@ func parseAPTCacheShowOutput(out []byte) []PackageInfo { continue } - info := PackageInfo{} - key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) switch key { - case "PackageInfo": - info.Name = value - case "Architecture": - info.Architecture = value - case "Version": - info.Version = value - case "Size": - info.Size = value - case "Description": - info.Description = value + case "Package": + if currentPackage != nil { + packageInfos = append(packageInfos, *currentPackage) + } + currentPackage = &PackageInfo{} + + currentPackage.Name = value + case fieldArchitecture: + if currentPackage != nil { + currentPackage.Architecture = value + } + case fieldVersion: + if currentPackage != nil { + currentPackage.Version = value + } + case fieldSize: + if currentPackage != nil { + currentPackage.Size = value + } + case fieldDescription: + if currentPackage != nil { + currentPackage.Description = value + } case "Installed-Size": - size, err := strconv.Atoi(value) - if err != nil { - // Ignore error - size = 0 + if currentPackage != nil { + size, err := strconv.Atoi(value) + if err != nil { + // Ignore error + size = 0 + } + currentPackage.InstalledSizeKB = size } - info.InstalledSizeKB = size } + } - packageInfos = append(packageInfos, info) + if currentPackage != nil { + packageInfos = append(packageInfos, *currentPackage) } return packageInfos @@ -185,7 +195,7 @@ func (apt *apt) Remove(_ context.Context, packs ...string) error { return cmd.Run() } -// Remove removes a set of packages. +// Purge removes a set of packages and their configuration. func (apt *apt) Purge(_ context.Context, packs ...string) error { args := []string{"purge", "-y"} for _, pack := range packs { @@ -206,674 +216,18 @@ func (apt *apt) Purge(_ context.Context, packs ...string) error { return cmd.Run() } -type extendedAPT struct { - packages map[string]pmapt.PackageConfig - apt *apt -} - -func newExtendedAPT(osinfo osinfo.Info, apt *apt) (*extendedAPT, error) { - packages, err := pmapt.LoadPackages(osinfo) +func newExtendedAPT(osInfo osinfo.Info, apt *apt) (*extended, error) { + packages, err := pkgconfig.LoadPackages("apt", osInfo) if err != nil { return nil, errors.WithMessage(err, "failed to load package configurations") } - return &extendedAPT{ - packages: packages, - apt: apt, - }, nil -} - -func (e *extendedAPT) Search(ctx context.Context, name string) ([]PackageInfo, error) { - return e.apt.Search(ctx, name) -} - -func (e *extendedAPT) Install(ctx context.Context, pack string, opts ...InstallOptions) error { - var err error - - options := &installOptions{} - for _, opt := range opts { - opt(options) - } - - packs, err := e.excludeByLookupPathFound(ctx, pack) - if err != nil { - return errors.WithMessage(err, "failed to check lookup paths") - } - - if len(packs) == 0 { - return nil - } - - err = e.installDependencies(ctx, packs...) - if err != nil { - return errors.WithMessage(err, "failed to install dependencies") - } - - packs, err = e.preInstallationSteps(ctx, lo.Uniq(append(packs, pack)), options) - if err != nil { - return errors.WithMessage(err, "failed to run pre installation steps") - } - - packs = e.replaceAliases(ctx, packs) - - packs, err = e.executeInstallSteps(ctx, packs, options) - if err != nil { - return errors.WithMessage(err, "failed to run install steps") - } - - for _, p := range packs { - err = e.apt.Install(ctx, p, opts...) - if err != nil { - return errors.WithMessage(err, "failed to install packages") - } - } - - err = e.postInstallationSteps(ctx, lo.Uniq(append(packs, pack)), options) - if err != nil { - return errors.WithMessage(err, "failed to run post-installation steps") - } - - return nil -} - -func (e *extendedAPT) CheckForUpdates(ctx context.Context) error { - return e.apt.CheckForUpdates(ctx) -} - -func (e *extendedAPT) Remove(ctx context.Context, packs ...string) error { - err := e.preRemovingSteps(ctx, packs...) - if err != nil { - return errors.WithMessage(err, "failed preRemovingSteps") - } - packs = e.replaceAliases(ctx, packs) - - return e.apt.Remove(ctx, packs...) -} - -func (e *extendedAPT) Purge(ctx context.Context, packs ...string) error { - err := e.preRemovingSteps(ctx, packs...) - if err != nil { - return errors.WithMessage(err, "failed preRemovingSteps") - } - packs = e.replaceAliases(ctx, packs) - - return e.apt.Purge(ctx, packs...) -} - -func (e *extendedAPT) replaceAliases(_ context.Context, packs []string) []string { - replacedPacks := make([]string, 0, len(packs)) - - for _, packName := range packs { - if pkgConfig, exists := e.packages[packName]; exists && pkgConfig.ReplaceWith != nil { - replacedPacks = append(replacedPacks, pkgConfig.ReplaceWith...) - } else { - replacedPacks = append(replacedPacks, packName) - } - } - - return replacedPacks -} - -//nolint:unparam -func (e *extendedAPT) excludeByLookupPathFound(_ context.Context, packs ...string) ([]string, error) { - filteredPacks := make([]string, 0, len(packs)) - - for _, packName := range packs { - config, exists := e.packages[packName] - if !exists || len(config.LookupPaths) == 0 { - filteredPacks = append(filteredPacks, packName) - - continue - } - - found := false - for _, lookupPath := range config.LookupPaths { - if _, err := exec.LookPath(lookupPath); err == nil { - found = true - - break - } - } - - if !found { - filteredPacks = append(filteredPacks, packName) - } - } - - return filteredPacks, nil -} - -func (e *extendedAPT) installDependencies(ctx context.Context, packs ...string) error { - dependencies := make([]string, 0) - - for _, packName := range packs { - config, exists := e.packages[packName] - if !exists { - continue - } - - dependencies = append(dependencies, config.Dependencies...) - } - - if len(dependencies) == 0 { - return nil - } - - for _, dep := range dependencies { - err := e.apt.Install(ctx, dep) - if err != nil { - return errors.WithMessage(err, "failed to install dependencies") - } - } - - return nil -} - -func (e *extendedAPT) preInstallationSteps( - ctx context.Context, packs []string, options *installOptions, -) ([]string, error) { - err := e.executePreInstallationSteps(ctx, packs, options) - if err != nil { - return nil, errors.WithMessage(err, "failed to run pre-installation steps") - } - - updatedPacks := make([]string, 0, len(packs)) - - // Hardcode. To be refactored later. - for _, pack := range packs { - switch pack { - case PHPPackage, PHPExtensionsPackage: - err := e.installAPTRepositoriesDependencies(ctx) - if err != nil { - return nil, err - } - - packages, err := e.findPHPPackages(ctx) - if err != nil { - return nil, err - } - - updatedPacks = append(updatedPacks, packages...) - case ApachePackage: - err := e.apachePackageProcess(ctx) - if err != nil { - return nil, errors.WithMessage(err, "failed to process apache packages") - } - default: - updatedPacks = append(updatedPacks, pack) - } - } - - return updatedPacks, nil -} - -func (e *extendedAPT) postInstallationSteps(ctx context.Context, packs []string, options *installOptions) error { - executedPackages := make(map[string]bool) - - for _, packName := range packs { - config, exists := e.packages[packName] - if !exists { - continue - } - - if len(config.PostInstall) == 0 { - continue - } - - if executedPackages[packName] { - continue - } - - runtimeVars := aptRuntimeTemplateVariables{ - LookupPaths: make(map[string]string, len(config.LookupPaths)), - Options: options, - } - - for _, lookupPath := range config.LookupPaths { - if resolvedPath, err := exec.LookPath(lookupPath); err == nil { - runtimeVars.LookupPaths[lookupPath] = resolvedPath - } - } - - for _, step := range config.PostInstall { - for _, cmd := range step.RunCommands { - processedCmd, err := e.replaceRuntimeVariablesString(ctx, cmd, runtimeVars) - if err != nil { - return errors.WithMessagef( - err, - "failed to replace runtime variables in post-install command for %s: %s", packName, cmd, - ) - } - - if err := e.executeCommand(ctx, processedCmd); err != nil { - return errors.WithMessagef( - err, - "failed to execute post-install command for %s: %s", packName, processedCmd, - ) - } - } - } - - executedPackages[packName] = true - } - - return nil -} - -func (e *extendedAPT) executePreInstallationSteps(ctx context.Context, packs []string, options *installOptions) error { - executedPackages := make(map[string]bool) - - for _, packName := range packs { - config, exists := e.packages[packName] - if !exists { - continue - } - - if len(config.PreInstall) == 0 { - continue - } - - if executedPackages[packName] { - continue - } - - runtimeVars := aptRuntimeTemplateVariables{ - LookupPaths: make(map[string]string, len(config.LookupPaths)), - Options: options, - } - - for _, lookupPath := range config.LookupPaths { - if resolvedPath, err := exec.LookPath(lookupPath); err == nil { - runtimeVars.LookupPaths[lookupPath] = resolvedPath - } - } - - for _, step := range config.PreInstall { - if !e.checkConditions(step.Conditions) { - continue - } - - for _, cmd := range step.RunCommands { - processedCmd, err := e.replaceRuntimeVariablesString(ctx, cmd, runtimeVars) - if err != nil { - return errors.WithMessagef( - err, - "failed to replace runtime variables in pre-install command for %s: %s", packName, cmd, - ) - } - - if err := e.executeCommand(ctx, processedCmd); err != nil { - return errors.WithMessagef( - err, - "failed to execute pre-install command for %s: %s", packName, processedCmd, - ) - } - } - } - - executedPackages[packName] = true - } - - return nil -} - -func (e *extendedAPT) executeInstallSteps( - ctx context.Context, packs []string, options *installOptions, -) ([]string, error) { - executedPackages := make(map[string]bool) - packsToInstall := make([]string, 0, len(packs)) - - for _, packName := range packs { - config, exists := e.packages[packName] - if !exists || len(config.Install) == 0 { - packsToInstall = append(packsToInstall, packName) - - continue - } - - if executedPackages[packName] { - continue - } - - runtimeVars := aptRuntimeTemplateVariables{ - LookupPaths: make(map[string]string, len(config.LookupPaths)), - Options: options, - } - - for _, lookupPath := range config.LookupPaths { - if resolvedPath, err := exec.LookPath(lookupPath); err == nil { - runtimeVars.LookupPaths[lookupPath] = resolvedPath - } - } - - for _, step := range config.Install { - for _, cmd := range step.RunCommands { - processedCmd, err := e.replaceRuntimeVariablesString(ctx, cmd, runtimeVars) - if err != nil { - return nil, errors.WithMessagef( - err, - "failed to replace runtime variables in install command for %s: %s", packName, cmd, - ) - } - - if err := e.executeCommand(ctx, processedCmd); err != nil { - return nil, errors.WithMessagef( - err, - "failed to execute install command for %s: %s", packName, processedCmd, - ) - } - } - } - - executedPackages[packName] = true - } - - return packsToInstall, nil -} - -func (e *extendedAPT) checkConditions(conditions []pmapt.Condition) bool { - if len(conditions) == 0 { - return true - } - - for _, condition := range conditions { - if condition.FileNotExists != "" { - if _, err := os.Stat(condition.FileNotExists); err == nil { - return false - } - } - } - - return true -} - -func (e *extendedAPT) executeCommand(ctx context.Context, cmdStr string) error { - cmdStr = strings.TrimSpace(cmdStr) - if cmdStr == "" { - return nil - } - - command := "bash" - - if _, err := exec.LookPath(command); err != nil { - command = "sh" - } - - args := []string{"-c", cmdStr} - - return oscore.ExecCommand(ctx, command, args...) -} - -func (e *extendedAPT) preRemovingSteps(ctx context.Context, packs ...string) error { - osInfo := contextInternal.OSInfoFromContext(ctx) - - for _, pack := range packs { - if pack == MySQLServerPackage && - (osInfo.Distribution == DistributionUbuntu || osInfo.Distribution == DistributionDebian) { - err := e.apt.Purge(ctx, "mysql*") - if err != nil { - return errors.WithMessage(err, "failed to purge mysql packages") - } - } - } - - return nil -} - -func (e *extendedAPT) installAPTRepositoriesDependencies(ctx context.Context) error { - installPackages := make([]string, 0, 2) - - pk, err := e.apt.Search(ctx, "software-properties-common") - if err != nil { - return err - } - if len(pk) > 0 { - installPackages = append(installPackages, "software-properties-common") - } - - pk, err = e.apt.Search(ctx, "apt-transport-https") - if err != nil { - return err - } - if len(pk) > 0 { - installPackages = append(installPackages, "apt-transport-https") - } - - for _, pkg := range installPackages { - err = e.apt.Install(ctx, pkg) - if err != nil { - return err - } - } - - return nil -} - -//nolint:gocognit,funlen -func (e *extendedAPT) findPHPPackages(ctx context.Context) ([]string, error) { - var versionAvailable string - log.Println("Searching for PHP packages...") - - var repoAdded bool - for { - log.Println("Checking for PHP 8.4 version availability...") - pk, err := e.apt.Search(ctx, "php8.4") - if err != nil { - return nil, err - } - if len(pk) > 0 { - versionAvailable = "8.4" - log.Println("PHP 8.4 version found") - - break - } - - log.Println("PHP 8.4 version not found") - - log.Println("Checking for PHP 8.3 version availability...") - pk, err = e.apt.Search(ctx, "php8.3") - if err != nil { - return nil, err - } - if len(pk) > 0 { - versionAvailable = "8.3" - log.Println("PHP 8.3 version found") - - break - } - - log.Println("PHP 8.3 version not found") - - log.Println("Checking for PHP 8.2 version availability...") - pk, err = e.apt.Search(ctx, "php8.2") - if err != nil { - return nil, err - } - if len(pk) > 0 { - versionAvailable = "8.2" - log.Println("PHP 8.2 version found") - - break - } - - log.Println("PHP 8.2 version not found") - - log.Println("Checking for PHP 8.1 version availability...") - pk, err = e.apt.Search(ctx, "php8.1") - if err != nil { - return nil, err - } - if len(pk) > 0 { - versionAvailable = "8.1" - log.Println("PHP 8.1 version found") - - break - } - - log.Println("PHP 8.1 version not found") - - pk, err = e.apt.Search(ctx, "php8.0") - if err != nil { - return nil, err - } - if len(pk) > 0 { - versionAvailable = "8.0" - log.Println("PHP 8.0 version found") - - break - } - - log.Println("PHP 8.0 version not found") - - pk, err = e.apt.Search(ctx, "php7.4") - if err != nil { - return nil, err - } - if len(pk) > 0 { - versionAvailable = "7.4" - log.Println("PHP 7.4 version found") - - break - } - - log.Println("PHP 7.4 version not found") - - pk, err = e.apt.Search(ctx, "php7.3") - if err != nil { - return nil, err - } - if len(pk) > 0 { - versionAvailable = "7.3" - log.Println("PHP 7.3 version found") - - break - } - - log.Println("PHP 7.3 version not found") - - if repoAdded { - return nil, errors.New("php version not found") - } - - repoAdded, err = e.addPHPRepositories(ctx) - if err != nil { - return nil, err - } - } - - packages := []string{ - "php" + versionAvailable + "-bcmath", - "php" + versionAvailable + "-bz2", - "php" + versionAvailable + "-cli", - "php" + versionAvailable + "-curl", - "php" + versionAvailable + "-fpm", - "php" + versionAvailable + "-gd", - "php" + versionAvailable + "-gmp", - "php" + versionAvailable + "-intl", - "php" + versionAvailable + "-mbstring", - "php" + versionAvailable + "-mysql", - "php" + versionAvailable + "-opcache", - "php" + versionAvailable + "-pgsql", - "php" + versionAvailable + "-sqlite", - "php" + versionAvailable + "-readline", - "php" + versionAvailable + "-xml", - "php" + versionAvailable + "-zip", - } - - return packages, nil -} - -func (e *extendedAPT) addPHPRepositories(ctx context.Context) (bool, error) { - osInfo := contextInternal.OSInfoFromContext(ctx) - - if osInfo.Distribution == DistributionUbuntu { - cmd := exec.Command("add-apt-repository", "ppa:ondrej/php") - cmd.Env = append(cmd.Env, "LC_ALL=C.UTF-8") - cmd.Stderr = log.Writer() - cmd.Stdout = log.Writer() - err := cmd.Run() - if err != nil { - return false, err - } - - return true, nil - } - - if osInfo.Distribution == DistributionDebian { - if !utils.IsFileExists(sourcesListPHP) { - err := utils.WriteContentsToFile( - []byte(fmt.Sprintf("deb https://packages.sury.org/php/ %s main", osInfo.DistributionCodename)), - sourcesListPHP, - ) - if err != nil { - return false, err - } - } - - err := utils.DownloadFile(ctx, "https://packages.sury.org/php/apt.gpg", "/etc/apt/trusted.gpg.d/php.gpg") - if err != nil { - return false, err - } - - return true, nil - } - - return false, nil -} - -func (e *extendedAPT) apachePackageProcess(ctx context.Context) error { - phpVersion, err := DefinePHPVersion() - if err != nil { - return errors.WithMessage(err, "failed to define php version") - } - - err = e.apt.Install(ctx, ApachePackage) - if err != nil { - return err - } - err = e.apt.Install(ctx, "libapache2-mod-php"+phpVersion) - if err != nil { - return err - } - - return nil -} - -type aptRuntimeTemplateVariables struct { - LookupPaths map[string]string - Options *installOptions -} - -func (e *extendedAPT) replaceRuntimeVariablesString( - _ context.Context, v string, vars aptRuntimeTemplateVariables, -) (string, error) { - funcMap := template.FuncMap{ - "configValue": func(name string) string { - if vars.Options == nil { - return "" - } - - val, exists := vars.Options.configValues[name] - if !exists { - return "" - } - - return val + return &extended{ + packages: packages, + underlined: apt, + strategy: &aptStrategy{ + packages: packages, + apt: apt, }, - } - - tmpl, err := template.New("package").Funcs(runtimeTemplateFuncMap).Funcs(funcMap).Parse(v) - if err != nil { - return "", errors.Wrap(err, "failed to parse template") - } - - var buf bytes.Buffer - buf.Grow(len(v) + 100) //nolint:mnd - - err = tmpl.Execute(&buf, vars) - if err != nil { - return "", errors.Wrap(err, "failed to execute template") - } - - return buf.String(), nil + }, nil } diff --git a/pkg/package_manager/apt/apt_test.go b/pkg/package_manager/apt/apt_test.go deleted file mode 100644 index fc14b1e..0000000 --- a/pkg/package_manager/apt/apt_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package apt - -import ( - "testing" - - osinfo "github.com/gameap/gameapctl/pkg/os_info" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLoadPackages_Default(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{}) - require.NoError(t, err) - require.NotEmpty(t, packages) - - t.Run("postgresql package", func(t *testing.T) { - pkg, exists := packages["postgresql"] - require.True(t, exists, "postgresql should exist in default.yaml") - assert.Equal(t, "postgresql", pkg.Name) - assert.Equal(t, []string{"postgresql", "postgresql-contrib"}, pkg.ReplaceWith) - assert.Empty(t, pkg.PreInstall) - require.Len(t, pkg.PostInstall, 2) - assert.Empty(t, pkg.Install) - }) - - t.Run("lib32gcc package", func(t *testing.T) { - pkg, exists := packages["lib32gcc"] - require.True(t, exists, "lib32gcc should exist in default.yaml") - assert.Equal(t, "lib32gcc", pkg.Name) - assert.Empty(t, pkg.ReplaceWith) - }) - - t.Run("composer package with install", func(t *testing.T) { - pkg, exists := packages["composer"] - require.True(t, exists, "composer should exist in default.yaml") - assert.Equal(t, "composer", pkg.Name) - require.Len(t, pkg.Install, 1) - require.Len(t, pkg.Install[0].RunCommands, 1) - assert.Contains(t, pkg.Install[0].RunCommands[0], "curl -sS https://getcomposer.org/installer") - assert.Contains(t, pkg.Install[0].RunCommands[0], "php -- --install-dir=/usr/local/bin --filename=composer") - }) - - t.Run("nodejs with pre-install", func(t *testing.T) { - pkg, exists := packages["nodejs"] - require.True(t, exists, "nodejs should exist in default.yaml") - assert.Equal(t, "nodejs", pkg.Name) - require.Len(t, pkg.PreInstall, 2) - require.Len(t, pkg.PreInstall[0].RunCommands, 2) - require.Len(t, pkg.PreInstall[1].RunCommands, 2) - assert.Equal(t, "mkdir -p /usr/share/keyrings/", pkg.PreInstall[0].RunCommands[0]) - assert.Contains(t, pkg.PreInstall[0].RunCommands[1], "curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key") - assert.Contains(t, pkg.PreInstall[0].RunCommands[1], "gpg --dearmor -o /usr/share/keyrings/nodesource.gpg") - assert.Contains(t, pkg.PreInstall[1].RunCommands[0], "deb [signed-by=/usr/share/keyrings/nodesource.gpg]") - assert.Contains(t, pkg.PreInstall[1].RunCommands[0], "https://deb.nodesource.com/node_24.x") - }) -} - -func TestLoadPackages_ARM64(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Platform: osinfo.PlatformArm64, - }) - require.NoError(t, err) - require.NotEmpty(t, packages) - - t.Run("lib32gcc with arm64 replacement", func(t *testing.T) { - pkg, exists := packages["lib32gcc"] - require.True(t, exists, "lib32gcc should exist in default_arm64.yaml") - assert.Equal(t, "lib32gcc", pkg.Name) - assert.Equal(t, []string{"lib32gcc-s1-amd64-cross"}, pkg.ReplaceWith) - }) - - t.Run("lib32stdc++6 with arm64 replacement", func(t *testing.T) { - pkg, exists := packages["lib32stdc++6"] - require.True(t, exists, "lib32stdc++6 should exist in default_arm64.yaml") - assert.Equal(t, "lib32stdc++6", pkg.Name) - assert.Equal(t, []string{"lib32stdc++6-amd64-cross"}, pkg.ReplaceWith) - }) - - t.Run("lib32z1 with empty replacement", func(t *testing.T) { - pkg, exists := packages["lib32z1"] - require.True(t, exists, "lib32z1 should exist in default_arm64.yaml") - assert.Equal(t, "lib32z1", pkg.Name) - assert.Empty(t, pkg.ReplaceWith) - }) -} - -func TestLoadPackages_Debian12(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionDebian, - DistributionVersion: "12", - }) - require.NoError(t, err) - require.NotEmpty(t, packages) - - t.Run("mysql-server replaced with default-mysql-server", func(t *testing.T) { - pkg, exists := packages["mysql-server"] - require.True(t, exists, "mysql-server should exist in debian_12.yaml") - assert.Equal(t, "mysql-server", pkg.Name) - assert.Equal(t, []string{"default-mysql-server"}, pkg.ReplaceWith) - }) - - t.Run("lib32gcc replaced with lib32gcc-s1", func(t *testing.T) { - pkg, exists := packages["lib32gcc"] - require.True(t, exists, "lib32gcc should exist in debian_12.yaml") - assert.Equal(t, "lib32gcc", pkg.Name) - assert.Equal(t, []string{"lib32gcc-s1"}, pkg.ReplaceWith) - }) - - t.Run("nodejs replaced with nodejs and npm", func(t *testing.T) { - pkg, exists := packages["nodejs"] - require.True(t, exists, "nodejs should exist in debian_12.yaml") - assert.Equal(t, "nodejs", pkg.Name) - assert.Equal(t, []string{"nodejs", "npm"}, pkg.ReplaceWith) - }) -} - -func TestLoadPackages_DebianSid(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionDebian, - DistributionCodename: "sid", - }) - require.NoError(t, err) - require.NotEmpty(t, packages) - - t.Run("mysql-server replaced with default-mysql-server", func(t *testing.T) { - pkg, exists := packages["mysql-server"] - require.True(t, exists, "mysql-server should exist in debian_sid.yaml") - assert.Equal(t, "mysql-server", pkg.Name) - assert.Equal(t, []string{"default-mysql-server"}, pkg.ReplaceWith) - }) - - t.Run("lib32gcc replaced with lib32gcc-s1", func(t *testing.T) { - pkg, exists := packages["lib32gcc"] - require.True(t, exists, "lib32gcc should exist in debian_sid.yaml") - assert.Equal(t, "lib32gcc", pkg.Name) - assert.Equal(t, []string{"lib32gcc-s1"}, pkg.ReplaceWith) - }) -} - -func TestLoadPackages_FileHierarchy(t *testing.T) { - t.Run("default overridden by arch-specific", func(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Platform: osinfo.PlatformArm64, - }) - require.NoError(t, err) - - pkg, exists := packages["lib32gcc"] - require.True(t, exists) - assert.Equal(t, []string{"lib32gcc-s1-amd64-cross"}, pkg.ReplaceWith) - }) - - t.Run("default overridden by distribution version", func(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionDebian, - DistributionVersion: "12", - }) - require.NoError(t, err) - - pkg, exists := packages["lib32gcc"] - require.True(t, exists) - assert.Equal(t, []string{"lib32gcc-s1"}, pkg.ReplaceWith) - }) - - t.Run("default overridden by distribution codename", func(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionDebian, - DistributionCodename: "sid", - }) - require.NoError(t, err) - - pkg, exists := packages["lib32gcc"] - require.True(t, exists) - assert.Equal(t, []string{"lib32gcc-s1"}, pkg.ReplaceWith) - }) -} - -func TestReplaceDistributionVariables(t *testing.T) { - osinf := osinfo.Info{ - Distribution: osinfo.DistributionDebian, - DistributionVersion: "12", - DistributionCodename: "bookworm", - Platform: osinfo.PlatformAmd64, - } - - t.Run("replace all variables", func(t *testing.T) { - input := "deb {{distname}} {{distversion}} {{codename}} {{architecture}}" - expected := "deb debian 12 bookworm amd64" - assert.Equal(t, expected, replaceDistributionVariables(input, osinf)) - }) - - t.Run("replace no variables", func(t *testing.T) { - input := "simple string" - expected := "simple string" - assert.Equal(t, expected, replaceDistributionVariables(input, osinf)) - }) - - t.Run("replace single variable", func(t *testing.T) { - input := "Distribution: {{distname}}" - expected := "Distribution: debian" - assert.Equal(t, expected, replaceDistributionVariables(input, osinf)) - }) - - t.Run("replace multiple occurrences", func(t *testing.T) { - input := "{{distname}} {{distname}} {{distname}}" - expected := "debian debian debian" //nolint:dupword - assert.Equal(t, expected, replaceDistributionVariables(input, osinf)) - }) -} - -func TestReplaceDistributionVariablesSlice(t *testing.T) { - osinf := osinfo.Info{ - Distribution: osinfo.DistributionDebian, - DistributionVersion: "12", - DistributionCodename: "bookworm", - Platform: osinfo.PlatformAmd64, - } - - t.Run("replace variables in slice", func(t *testing.T) { - inputs := []string{ - "deb {{distname}}", - "version {{distversion}}", - "codename {{codename}}", - "arch {{architecture}}", - } - expected := []string{ - "deb debian", - "version 12", - "codename bookworm", - "arch amd64", - } - result := replaceDistributionVariablesSlice(inputs, osinf) - assert.Equal(t, expected, result) - }) - - t.Run("empty slice", func(t *testing.T) { - inputs := []string{} - result := replaceDistributionVariablesSlice(inputs, osinf) - assert.Empty(t, result) - }) - - t.Run("slice with no variables", func(t *testing.T) { - inputs := []string{"foo", "bar", "baz"} - expected := []string{"foo", "bar", "baz"} - result := replaceDistributionVariablesSlice(inputs, osinf) - assert.Equal(t, expected, result) - }) -} - -func Test_normalizeCommand(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "single line command", - input: "apt-get install -y postgresql", - expected: "apt-get install -y postgresql", - }, - { - name: "multiline command with newlines", - input: `curl -fsSL https://example.com/key.gpg - | gpg --dearmor - -o /usr/share/keyrings/example.gpg`, - expected: `curl -fsSL https://example.com/key.gpg | gpg --dearmor -o /usr/share/keyrings/example.gpg`, - }, - { - name: "multiple consecutive spaces", - input: "apt-get install -y package", - expected: "apt-get install -y package", - }, - { - name: "leading and trailing spaces", - input: " apt-get update ", - expected: "apt-get update", - }, - { - name: "tabs and spaces mixed", - input: "echo\t\ttest\tcommand", - expected: "echo test command", - }, - { - name: "windows line endings (CRLF)", - input: "line1\r\nline2\r\nline3", - expected: "line1 line2 line3", - }, - { - name: "unix line endings (LF)", - input: "line1\nline2\nline3", - expected: "line1 line2 line3", - }, - { - name: "old mac line endings (CR)", - input: "line1\rline2\rline3", - expected: "line1 line2 line3", - }, - { - name: "empty string", - input: "", - expected: "", - }, - { - name: "only whitespace", - input: " \n\t\r\n ", - expected: "", - }, - { - name: "real command from yaml", - input: `curl -sS https://getcomposer.org/installer - | php -- - --install-dir=/usr/local/bin - --filename=composer`, - expected: `curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := normalizeCommand(tt.input) - assert.Equal(t, tt.expected, got) - }) - } -} diff --git a/pkg/package_manager/apt/apt_search.go b/pkg/package_manager/apt_search.go similarity index 79% rename from pkg/package_manager/apt/apt_search.go rename to pkg/package_manager/apt_search.go index fe2d2c8..e1e185d 100644 --- a/pkg/package_manager/apt/apt_search.go +++ b/pkg/package_manager/apt_search.go @@ -1,4 +1,4 @@ -package apt +package packagemanager import ( "bufio" @@ -16,7 +16,15 @@ import ( const aptListPath = "/var/lib/apt/lists/" -type Package struct { +// Field keys shared by the apt-cache, apt list and dnf/yum info parsers. +const ( + fieldVersion = "Version" + fieldArchitecture = "Architecture" + fieldSize = "Size" + fieldDescription = "Description" +) + +type aptListPackage struct { PackageName string Version string Architecture string @@ -29,15 +37,7 @@ type Package struct { Sha256 string } -type RepoArchive struct { - Domain string - Distribution string - Area string - Architecture string - ListFileName string -} - -func Search(q string) ([]Package, error) { +func aptListSearch(q string) ([]aptListPackage, error) { packages, err := aptListAllPackages() if err != nil { return nil, errors.WithMessage(err, "failed to list all packages") @@ -48,8 +48,8 @@ func Search(q string) ([]Package, error) { // aptSearch allows to perform a targeted search using the exact name of the package to be searched, // or a keyword search that will result in all packages that include that string in the name. -func aptSearch(searchPackage string, packagesList []Package, searchExactName bool) ([]Package, error) { - var filteredPackageList []Package +func aptSearch(searchPackage string, packagesList []aptListPackage, searchExactName bool) ([]aptListPackage, error) { + var filteredPackageList []aptListPackage for _, singlePackage := range packagesList { if searchExactName { if singlePackage.PackageName == searchPackage { @@ -65,8 +65,8 @@ func aptSearch(searchPackage string, packagesList []Package, searchExactName boo return filteredPackageList, nil } -// AptListALL scan the all source.list on the system and return the list of all available packages. -func aptListAllPackages() ([]Package, error) { +// aptListAllPackages scans all source.list on the system and returns the list of all available packages. +func aptListAllPackages() ([]aptListPackage, error) { allPackagesFiles, errGetRepoFileList := getRepoFileList() if errGetRepoFileList != nil { return nil, errGetRepoFileList @@ -101,8 +101,8 @@ func getRepoFileList() ([]string, error) { } // buildPackagesList: return packages available from a list of repositories. -func buildPackagesList(repoList []string) ([]Package, error) { - var packageList []Package +func buildPackagesList(repoList []string) ([]aptListPackage, error) { + var packageList []aptListPackage for _, packagesFile := range repoList { f, errOpen := os.Open(filepath.Join(aptListPath, packagesFile)) if errOpen != nil { @@ -125,7 +125,7 @@ func buildPackagesList(repoList []string) ([]Package, error) { scanner := bufio.NewScanner(r) - var p Package + var p aptListPackage for scanner.Scan() { line := scanner.Text() @@ -133,7 +133,7 @@ func buildPackagesList(repoList []string) ([]Package, error) { if line == "" { if p.PackageName != "" { packageList = append(packageList, p) - p = Package{} + p = aptListPackage{} } continue @@ -150,17 +150,17 @@ func buildPackagesList(repoList []string) ([]Package, error) { switch key { case "Package": p.PackageName = value - case "Version": + case fieldVersion: p.Version = value - case "Architecture": + case fieldArchitecture: p.Architecture = value case "Depends": p.Depends = strings.Split(value, ",") - case "Size": + case fieldSize: p.Size = value case "Installed-Size": p.InstalledSize = value - case "Description": + case fieldDescription: p.Description = value case "Section": p.Section = value diff --git a/pkg/package_manager/apt_strategy.go b/pkg/package_manager/apt_strategy.go new file mode 100644 index 0000000..1761aee --- /dev/null +++ b/pkg/package_manager/apt_strategy.go @@ -0,0 +1,313 @@ +package packagemanager + +import ( + "context" + "fmt" + "log" + "os/exec" + + contextInternal "github.com/gameap/gameapctl/internal/context" + "github.com/gameap/gameapctl/pkg/package_manager/pkgconfig" + "github.com/gameap/gameapctl/pkg/utils" + "github.com/pkg/errors" +) + +// aptStrategy implements packageInstallStrategy with the apt-specific logic: +// dependency installation, PHP/Apache repository handling and MySQL purge on +// removal. +type aptStrategy struct { + packages map[string]pkgconfig.PackageConfig + apt PackageManager +} + +func (s *aptStrategy) installDependencies(ctx context.Context, packs ...string) error { + dependencies := make([]string, 0) + + for _, packName := range packs { + config, exists := s.packages[packName] + if !exists { + continue + } + + dependencies = append(dependencies, config.Dependencies...) + } + + if len(dependencies) == 0 { + return nil + } + + for _, dep := range dependencies { + err := s.apt.Install(ctx, dep) + if err != nil { + return errors.WithMessage(err, "failed to install dependencies") + } + } + + return nil +} + +func (s *aptStrategy) transformPreInstallPackages(ctx context.Context, packs []string) ([]string, error) { + updatedPacks := make([]string, 0, len(packs)) + + // Hardcode. To be refactored later. + for _, pack := range packs { + switch pack { + case PHPPackage, PHPExtensionsPackage: + err := s.installAPTRepositoriesDependencies(ctx) + if err != nil { + return nil, err + } + + packages, err := s.findPHPPackages(ctx) + if err != nil { + return nil, err + } + + updatedPacks = append(updatedPacks, packages...) + case ApachePackage: + err := s.apachePackageProcess(ctx) + if err != nil { + return nil, errors.WithMessage(err, "failed to process apache packages") + } + default: + updatedPacks = append(updatedPacks, pack) + } + } + + return updatedPacks, nil +} + +func (s *aptStrategy) preRemove(ctx context.Context, packs ...string) error { + osInfo := contextInternal.OSInfoFromContext(ctx) + + for _, pack := range packs { + if pack == MySQLServerPackage && + (osInfo.Distribution == DistributionUbuntu || osInfo.Distribution == DistributionDebian) { + err := s.apt.Purge(ctx, "mysql*") + if err != nil { + return errors.WithMessage(err, "failed to purge mysql packages") + } + } + } + + return nil +} + +func (s *aptStrategy) installAPTRepositoriesDependencies(ctx context.Context) error { + installPackages := make([]string, 0, 2) + + pk, err := s.apt.Search(ctx, "software-properties-common") + if err != nil { + return err + } + if len(pk) > 0 { + installPackages = append(installPackages, "software-properties-common") + } + + pk, err = s.apt.Search(ctx, "apt-transport-https") + if err != nil { + return err + } + if len(pk) > 0 { + installPackages = append(installPackages, "apt-transport-https") + } + + for _, pkg := range installPackages { + err = s.apt.Install(ctx, pkg) + if err != nil { + return err + } + } + + return nil +} + +//nolint:gocognit,funlen +func (s *aptStrategy) findPHPPackages(ctx context.Context) ([]string, error) { + var versionAvailable string + log.Println("Searching for PHP packages...") + + var repoAdded bool + for { + log.Println("Checking for PHP 8.4 version availability...") + pk, err := s.apt.Search(ctx, "php8.4") + if err != nil { + return nil, err + } + if len(pk) > 0 { + versionAvailable = "8.4" + log.Println("PHP 8.4 version found") + + break + } + + log.Println("PHP 8.4 version not found") + + log.Println("Checking for PHP 8.3 version availability...") + pk, err = s.apt.Search(ctx, "php8.3") + if err != nil { + return nil, err + } + if len(pk) > 0 { + versionAvailable = "8.3" + log.Println("PHP 8.3 version found") + + break + } + + log.Println("PHP 8.3 version not found") + + log.Println("Checking for PHP 8.2 version availability...") + pk, err = s.apt.Search(ctx, "php8.2") + if err != nil { + return nil, err + } + if len(pk) > 0 { + versionAvailable = "8.2" + log.Println("PHP 8.2 version found") + + break + } + + log.Println("PHP 8.2 version not found") + + log.Println("Checking for PHP 8.1 version availability...") + pk, err = s.apt.Search(ctx, "php8.1") + if err != nil { + return nil, err + } + if len(pk) > 0 { + versionAvailable = "8.1" + log.Println("PHP 8.1 version found") + + break + } + + log.Println("PHP 8.1 version not found") + + pk, err = s.apt.Search(ctx, "php8.0") + if err != nil { + return nil, err + } + if len(pk) > 0 { + versionAvailable = "8.0" + log.Println("PHP 8.0 version found") + + break + } + + log.Println("PHP 8.0 version not found") + + pk, err = s.apt.Search(ctx, "php7.4") + if err != nil { + return nil, err + } + if len(pk) > 0 { + versionAvailable = "7.4" + log.Println("PHP 7.4 version found") + + break + } + + log.Println("PHP 7.4 version not found") + + pk, err = s.apt.Search(ctx, "php7.3") + if err != nil { + return nil, err + } + if len(pk) > 0 { + versionAvailable = "7.3" + log.Println("PHP 7.3 version found") + + break + } + + log.Println("PHP 7.3 version not found") + + if repoAdded { + return nil, errors.New("php version not found") + } + + repoAdded, err = s.addPHPRepositories(ctx) + if err != nil { + return nil, err + } + } + + packages := []string{ + "php" + versionAvailable + "-bcmath", + "php" + versionAvailable + "-bz2", + "php" + versionAvailable + "-cli", + "php" + versionAvailable + "-curl", + "php" + versionAvailable + "-fpm", + "php" + versionAvailable + "-gd", + "php" + versionAvailable + "-gmp", + "php" + versionAvailable + "-intl", + "php" + versionAvailable + "-mbstring", + "php" + versionAvailable + "-mysql", + "php" + versionAvailable + "-opcache", + "php" + versionAvailable + "-pgsql", + "php" + versionAvailable + "-sqlite", + "php" + versionAvailable + "-readline", + "php" + versionAvailable + "-xml", + "php" + versionAvailable + "-zip", + } + + return packages, nil +} + +func (s *aptStrategy) addPHPRepositories(ctx context.Context) (bool, error) { + osInfo := contextInternal.OSInfoFromContext(ctx) + + if osInfo.Distribution == DistributionUbuntu { + cmd := exec.Command("add-apt-repository", "ppa:ondrej/php") + cmd.Env = append(cmd.Env, "LC_ALL=C.UTF-8") + cmd.Stderr = log.Writer() + cmd.Stdout = log.Writer() + err := cmd.Run() + if err != nil { + return false, err + } + + return true, nil + } + + if osInfo.Distribution == DistributionDebian { + if !utils.IsFileExists(sourcesListPHP) { + err := utils.WriteContentsToFile( + []byte(fmt.Sprintf("deb https://packages.sury.org/php/ %s main", osInfo.DistributionCodename)), + sourcesListPHP, + ) + if err != nil { + return false, err + } + } + + err := utils.DownloadFile(ctx, "https://packages.sury.org/php/apt.gpg", "/etc/apt/trusted.gpg.d/php.gpg") + if err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} + +func (s *aptStrategy) apachePackageProcess(ctx context.Context) error { + phpVersion, err := DefinePHPVersion() + if err != nil { + return errors.WithMessage(err, "failed to define php version") + } + + err = s.apt.Install(ctx, ApachePackage) + if err != nil { + return err + } + err = s.apt.Install(ctx, "libapache2-mod-php"+phpVersion) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/package_manager/const.go b/pkg/package_manager/const.go index 83668d2..a21e3f5 100644 --- a/pkg/package_manager/const.go +++ b/pkg/package_manager/const.go @@ -30,6 +30,7 @@ const XZUtilsPackage = "xz-utils" const DistributionDefault = Default const DistributionAlmaLinux = osinfo.DistributionAlmaLinux const DistributionAmazon = osinfo.DistributionAmazon +const DistributionArch = osinfo.DistributionArch const DistributionCentOS = osinfo.DistributionCentOS const DistributionDebian = osinfo.DistributionDebian const DistributionRocky = osinfo.DistributionRocky diff --git a/pkg/package_manager/dnf.go b/pkg/package_manager/dnf.go index a4ad579..fde6f2b 100644 --- a/pkg/package_manager/dnf.go +++ b/pkg/package_manager/dnf.go @@ -6,14 +6,10 @@ import ( "log" "os" "os/exec" - "strings" - "text/template" osinfo "github.com/gameap/gameapctl/pkg/os_info" - "github.com/gameap/gameapctl/pkg/oscore" - pmdnf "github.com/gameap/gameapctl/pkg/package_manager/dnf" + "github.com/gameap/gameapctl/pkg/package_manager/pkgconfig" "github.com/pkg/errors" - "github.com/samber/lo" ) type dnf struct{} @@ -79,375 +75,15 @@ func (d *dnf) Purge(ctx context.Context, packs ...string) error { return d.Remove(ctx, packs...) } -type extendedDNF struct { - packages map[string]pmdnf.PackageConfig - underlined PackageManager -} - -func newExtendedDNF(osinfo osinfo.Info, underlined PackageManager) (*extendedDNF, error) { - packages, err := pmdnf.LoadPackages(osinfo) +func newExtendedDNF(osInfo osinfo.Info, underlined PackageManager) (*extended, error) { + packages, err := pkgconfig.LoadPackages("dnf", osInfo) if err != nil { return nil, errors.WithMessage(err, "failed to load dnf packages configuration") } - return &extendedDNF{ + return &extended{ packages: packages, underlined: underlined, + strategy: noopStrategy{}, }, nil } - -func (d *extendedDNF) Install(ctx context.Context, pack string, opts ...InstallOptions) error { - var err error - - options := &installOptions{} - for _, opt := range opts { - opt(options) - } - - packs, err := d.excludeByLookupPathFound(ctx, pack) - if err != nil { - return errors.WithMessage(err, "failed to check lookup paths") - } - - if len(packs) == 0 { - return nil - } - - packs, err = d.preInstallationSteps(ctx, lo.Uniq(append(packs, pack)), options) - if err != nil { - return errors.WithMessage(err, "failed to run pre-installation steps") - } - - packs = d.replaceAliases(ctx, packs) - - packs, err = d.executeInstallSteps(ctx, packs, options) - if err != nil { - return errors.WithMessage(err, "failed to run install steps") - } - - for _, p := range packs { - err = d.underlined.Install(ctx, p, opts...) - if err != nil { - return errors.WithMessage(err, "failed to install packages") - } - } - - err = d.postInstallationSteps(ctx, lo.Uniq(append(packs, pack)), options) - if err != nil { - return errors.WithMessage(err, "failed to run post-installation steps") - } - - return nil -} - -func (d *extendedDNF) CheckForUpdates(ctx context.Context) error { - return d.underlined.CheckForUpdates(ctx) -} - -func (d *extendedDNF) Remove(ctx context.Context, packs ...string) error { - packs = d.replaceAliases(ctx, packs) - - return d.underlined.Remove(ctx, packs...) -} - -func (d *extendedDNF) Purge(ctx context.Context, packs ...string) error { - packs = d.replaceAliases(ctx, packs) - - return d.underlined.Purge(ctx, packs...) -} - -func (d *extendedDNF) Search(ctx context.Context, name string) ([]PackageInfo, error) { - return d.underlined.Search(ctx, name) -} - -func (d *extendedDNF) replaceAliases(_ context.Context, packs []string) []string { - updatedPacks := make([]string, 0, len(packs)) - - for _, packName := range packs { - if config, exists := d.packages[packName]; exists && config.ReplaceWith != nil { - updatedPacks = append(updatedPacks, config.ReplaceWith...) - } else { - updatedPacks = append(updatedPacks, packName) - } - } - - return updatedPacks -} - -//nolint:unparam -func (d *extendedDNF) excludeByLookupPathFound(_ context.Context, packs ...string) ([]string, error) { - filteredPacks := make([]string, 0, len(packs)) - - for _, packName := range packs { - config, exists := d.packages[packName] - if !exists || len(config.LookupPaths) == 0 { - filteredPacks = append(filteredPacks, packName) - - continue - } - - found := false - for _, lookupPath := range config.LookupPaths { - if _, err := exec.LookPath(lookupPath); err == nil { - found = true - - break - } - } - - if !found { - filteredPacks = append(filteredPacks, packName) - } - } - - return filteredPacks, nil -} - -func (d *extendedDNF) executePreInstallationSteps(ctx context.Context, packs []string, options *installOptions) error { - executedPackages := make(map[string]bool) - - for _, packName := range packs { - config, exists := d.packages[packName] - if !exists { - continue - } - - if len(config.PreInstall) == 0 { - continue - } - - if executedPackages[packName] { - continue - } - - runtimeVars := dnfRuntimeTemplateVariables{ - LookupPaths: make(map[string]string, len(config.LookupPaths)), - Options: options, - } - - for _, lookupPath := range config.LookupPaths { - if resolvedPath, err := exec.LookPath(lookupPath); err == nil { - runtimeVars.LookupPaths[lookupPath] = resolvedPath - } - } - - for _, step := range config.PreInstall { - if !d.checkConditions(step.Conditions) { - continue - } - - for _, cmd := range step.RunCommands { - processedCmd, err := d.replaceRuntimeVariablesString(ctx, cmd, runtimeVars) - if err != nil { - return errors.WithMessagef( - err, - "failed to replace runtime variables in pre-install command for %s: %s", packName, cmd, - ) - } - - if err := d.executeCommand(ctx, processedCmd); err != nil { - return errors.WithMessagef( - err, - "failed to execute pre-install command for %s: %s", packName, processedCmd, - ) - } - } - } - - executedPackages[packName] = true - } - - return nil -} - -func (d *extendedDNF) checkConditions(conditions []pmdnf.Condition) bool { - if len(conditions) == 0 { - return true - } - - for _, condition := range conditions { - if condition.FileNotExists != "" { - if _, err := os.Stat(condition.FileNotExists); err == nil { - return false - } - } - } - - return true -} - -func (d *extendedDNF) preInstallationSteps( - ctx context.Context, packs []string, options *installOptions, -) ([]string, error) { - err := d.executePreInstallationSteps(ctx, packs, options) - if err != nil { - return nil, errors.WithMessage(err, "failed to run pre-installation steps") - } - - return packs, nil -} - -func (d *extendedDNF) executeInstallSteps( - ctx context.Context, packs []string, options *installOptions, -) ([]string, error) { - executedPackages := make(map[string]bool) - packsToInstall := make([]string, 0, len(packs)) - - for _, packName := range packs { - config, exists := d.packages[packName] - if !exists || len(config.Install) == 0 { - packsToInstall = append(packsToInstall, packName) - - continue - } - - if executedPackages[packName] { - continue - } - - runtimeVars := dnfRuntimeTemplateVariables{ - LookupPaths: make(map[string]string, len(config.LookupPaths)), - Options: options, - } - - for _, lookupPath := range config.LookupPaths { - if resolvedPath, err := exec.LookPath(lookupPath); err == nil { - runtimeVars.LookupPaths[lookupPath] = resolvedPath - } - } - - for _, step := range config.Install { - for _, cmd := range step.RunCommands { - processedCmd, err := d.replaceRuntimeVariablesString(ctx, cmd, runtimeVars) - if err != nil { - return nil, errors.WithMessagef( - err, - "failed to replace runtime variables in install command for %s: %s", packName, cmd, - ) - } - - if err := d.executeCommand(ctx, processedCmd); err != nil { - return nil, errors.WithMessagef( - err, - "failed to execute install command for %s: %s", packName, processedCmd, - ) - } - } - } - - executedPackages[packName] = true - } - - return packsToInstall, nil -} - -func (d *extendedDNF) postInstallationSteps(ctx context.Context, packs []string, options *installOptions) error { - executedPackages := make(map[string]bool) - - for _, packName := range packs { - config, exists := d.packages[packName] - if !exists { - continue - } - - if len(config.PostInstall) == 0 { - continue - } - - if executedPackages[packName] { - continue - } - - runtimeVars := dnfRuntimeTemplateVariables{ - LookupPaths: make(map[string]string, len(config.LookupPaths)), - Options: options, - } - - for _, lookupPath := range config.LookupPaths { - if resolvedPath, err := exec.LookPath(lookupPath); err == nil { - runtimeVars.LookupPaths[lookupPath] = resolvedPath - } - } - - for _, step := range config.PostInstall { - for _, cmd := range step.RunCommands { - processedCmd, err := d.replaceRuntimeVariablesString(ctx, cmd, runtimeVars) - if err != nil { - return errors.WithMessagef( - err, - "failed to replace runtime variables in post-install command for %s: %s", packName, cmd, - ) - } - - log.Println("Running post-install command:", processedCmd) - - if err := d.executeCommand(ctx, processedCmd); err != nil { - return errors.WithMessagef( - err, - "failed to execute post-install command for %s: %s", packName, processedCmd, - ) - } - } - } - - executedPackages[packName] = true - } - - return nil -} - -func (d *extendedDNF) executeCommand(ctx context.Context, cmdStr string) error { - cmdStr = strings.TrimSpace(cmdStr) - if cmdStr == "" { - return nil - } - - command := "bash" - - if _, err := exec.LookPath(command); err != nil { - command = "sh" - } - - args := []string{"-c", cmdStr} - - return oscore.ExecCommand(ctx, command, args...) -} - -type dnfRuntimeTemplateVariables struct { - LookupPaths map[string]string - Options *installOptions -} - -func (d *extendedDNF) replaceRuntimeVariablesString( - _ context.Context, v string, vars dnfRuntimeTemplateVariables, -) (string, error) { - funcMap := template.FuncMap{ - "configValue": func(name string) string { - if vars.Options == nil { - return "" - } - - val, exists := vars.Options.configValues[name] - if !exists { - return "" - } - - return val - }, - } - - tmpl, err := template.New("package").Funcs(runtimeTemplateFuncMap).Funcs(funcMap).Parse(v) - if err != nil { - return "", errors.Wrap(err, "failed to parse template") - } - - var buf bytes.Buffer - buf.Grow(len(v) + 100) //nolint:mnd - - err = tmpl.Execute(&buf, vars) - if err != nil { - return "", errors.Wrap(err, "failed to execute template") - } - - return buf.String(), nil -} diff --git a/pkg/package_manager/dnf/dnf.go b/pkg/package_manager/dnf/dnf.go deleted file mode 100644 index d2a1cc4..0000000 --- a/pkg/package_manager/dnf/dnf.go +++ /dev/null @@ -1,193 +0,0 @@ -package dnf - -import ( - "embed" - "fmt" - "log" - "strings" - "sync" - - osinfo "github.com/gameap/gameapctl/pkg/os_info" - "github.com/goccy/go-yaml" -) - -//go:embed *.yaml -var fs embed.FS - -type packagesConfig struct { - Packages []PackageConfig `yaml:"packages"` -} - -type Condition struct { - FileNotExists string `yaml:"file-not-exists"` -} - -type PreInstallStep struct { - Conditions []Condition `yaml:"conditions,omitempty"` - RunCommands []string `yaml:"run-commands,omitempty"` -} - -type InstallStep struct { - RunCommands []string `yaml:"run-commands,omitempty"` -} - -type PostInstallStep struct { - RunCommands []string `yaml:"run-commands,omitempty"` -} - -type PackageConfig struct { - Name string `yaml:"name"` - ReplaceWith []string `yaml:"replace-with"` - LookupPaths []string `yaml:"lookup-paths"` - PathEnv []string `yaml:"path-env"` - PreInstall []PreInstallStep `yaml:"pre-install"` - Install []InstallStep `yaml:"install"` - PostInstall []PostInstallStep `yaml:"post-install"` -} - -var ( - packageCache = make(map[string]map[string]PackageConfig) - packageCacheMutex sync.RWMutex -) - -func buildCacheKey(osinf osinfo.Info) string { - return fmt.Sprintf( - "%s_%s_%s", - osinf.Distribution.String(), - osinf.DistributionVersion, - osinf.Platform.String(), - ) -} - -func LoadPackages(osinf osinfo.Info) (map[string]PackageConfig, error) { - cacheKey := buildCacheKey(osinf) - - packageCacheMutex.RLock() - if cached, exists := packageCache[cacheKey]; exists { - packageCacheMutex.RUnlock() - - return cached, nil - } - packageCacheMutex.RUnlock() - - packageCacheMutex.Lock() - defer packageCacheMutex.Unlock() - - if cached, exists := packageCache[cacheKey]; exists { - return cached, nil - } - packages := make(map[string]PackageConfig) - - distribution := osinf.Distribution.String() - version := osinf.DistributionVersion - arch := osinf.Platform.String() - - filesToLoad := []string{ - "default.yaml", - } - - if arch != "" { - filesToLoad = append(filesToLoad, fmt.Sprintf("default_%s.yaml", arch)) - } - - if distribution != "" { - filesToLoad = append(filesToLoad, fmt.Sprintf("%s.yaml", strings.ToLower(distribution))) - } - - if distribution != "" && version != "" { - filesToLoad = append(filesToLoad, fmt.Sprintf("%s_%s.yaml", strings.ToLower(distribution), version)) - } - - if distribution != "" && version != "" && arch != "" { - filesToLoad = append(filesToLoad, fmt.Sprintf("%s_%s_%s.yaml", strings.ToLower(distribution), version, arch)) - } - - log.Printf("Loading packages from files: %v", filesToLoad) - - for _, filename := range filesToLoad { - data, err := fs.ReadFile(filename) - if err != nil { - continue - } - - var config packagesConfig - err = yaml.Unmarshal(data, &config) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal %s: %w", filename, err) - } - - for _, pkg := range config.Packages { - for i := range pkg.PreInstall { - pkg.PreInstall[i].RunCommands = normalizeCommandSlice( - replaceDistributionVariablesSlice(pkg.PreInstall[i].RunCommands, osinf), - ) - } - for i := range pkg.Install { - pkg.Install[i].RunCommands = normalizeCommandSlice( - replaceDistributionVariablesSlice(pkg.Install[i].RunCommands, osinf), - ) - } - for i := range pkg.PostInstall { - pkg.PostInstall[i].RunCommands = normalizeCommandSlice(replaceDistributionVariablesSlice( - pkg.PostInstall[i].RunCommands, osinf, - )) - } - - packages[pkg.Name] = pkg - } - } - - packageCache[cacheKey] = packages - - return packages, nil -} - -func replaceDistributionVariablesSlice(inputs []string, osinf osinfo.Info) []string { - results := make([]string, len(inputs)) - for i, input := range inputs { - results[i] = replaceDistributionVariables(input, osinf) - } - - return results -} - -func replaceDistributionVariables(input string, osinf osinfo.Info) string { - result := strings.ReplaceAll(input, "{{distname}}", osinf.Distribution.String()) - result = strings.ReplaceAll(result, "{{distversion}}", osinf.DistributionVersion) - result = strings.ReplaceAll(result, "{{codename}}", osinf.DistributionCodename) - result = strings.ReplaceAll(result, "{{architecture}}", osinf.Platform.String()) - - return result -} - -func normalizeCommandSlice(inputs []string) []string { - results := make([]string, len(inputs)) - for i, input := range inputs { - results[i] = normalizeCommand(input) - } - - return results -} - -func normalizeCommand(s string) string { - s = strings.ReplaceAll(s, "\r\n", " ") - s = strings.ReplaceAll(s, "\n", " ") - s = strings.ReplaceAll(s, "\r", " ") - - var normalized strings.Builder - prevSpace := false - - for _, r := range s { - if r == ' ' || r == '\t' { - if !prevSpace { - normalized.WriteRune(' ') - prevSpace = true - } - } else { - normalized.WriteRune(r) - prevSpace = false - } - } - - return strings.TrimSpace(normalized.String()) -} diff --git a/pkg/package_manager/dnf/dnf_test.go b/pkg/package_manager/dnf/dnf_test.go deleted file mode 100644 index 3adca99..0000000 --- a/pkg/package_manager/dnf/dnf_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package dnf - -import ( - "testing" - - osinfo "github.com/gameap/gameapctl/pkg/os_info" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLoadPackages_Default(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{}) - require.NoError(t, err) - require.NotEmpty(t, packages) - - t.Run("lib32gcc package", func(t *testing.T) { - pkg, exists := packages["lib32gcc"] - require.True(t, exists, "lib32gcc should exist in default.yaml") - assert.Equal(t, "lib32gcc", pkg.Name) - assert.Equal(t, []string{"libgcc.i686"}, pkg.ReplaceWith) - assert.Empty(t, pkg.PreInstall) - assert.Empty(t, pkg.PostInstall) - }) - - t.Run("lib32stdc6 package", func(t *testing.T) { - pkg, exists := packages["lib32stdc6"] - require.True(t, exists, "lib32stdc6 should exist in default.yaml") - assert.Equal(t, "lib32stdc6", pkg.Name) - assert.Equal(t, []string{"libstdc++.i686"}, pkg.ReplaceWith) - }) - - t.Run("xz-utils package", func(t *testing.T) { - pkg, exists := packages["xz-utils"] - require.True(t, exists, "xz-utils should exist in default.yaml") - assert.Equal(t, "xz-utils", pkg.Name) - assert.Equal(t, []string{"xz"}, pkg.ReplaceWith) - }) - - t.Run("php-extensions package", func(t *testing.T) { - pkg, exists := packages["php-extensions"] - require.True(t, exists, "php-extensions should exist in default.yaml") - assert.Equal(t, "php-extensions", pkg.Name) - assert.Contains(t, pkg.ReplaceWith, "php-bcmath") - assert.Contains(t, pkg.ReplaceWith, "php-gd") - assert.Contains(t, pkg.ReplaceWith, "php-xml") - }) - - t.Run("postgresql with post-install", func(t *testing.T) { - pkg, exists := packages["postgresql"] - require.True(t, exists, "postgresql should exist in default.yaml") - assert.Equal(t, "postgresql", pkg.Name) - assert.Equal(t, []string{"postgresql-server", "postgresql-contrib"}, pkg.ReplaceWith) - require.Len(t, pkg.PostInstall, 2) - require.Len(t, pkg.PostInstall[0].RunCommands, 3) - assert.Equal(t, "postgresql-setup --initdb", pkg.PostInstall[0].RunCommands[0]) - assert.Equal(t, "systemctl enable postgresql", pkg.PostInstall[0].RunCommands[1]) - assert.Equal(t, "systemctl start postgresql", pkg.PostInstall[0].RunCommands[2]) - }) - - t.Run("redis-server with post-install", func(t *testing.T) { - pkg, exists := packages["redis-server"] - require.True(t, exists, "redis-server should exist in default.yaml") - assert.Equal(t, "redis-server", pkg.Name) - assert.Equal(t, []string{"redis"}, pkg.ReplaceWith) - require.Len(t, pkg.PostInstall, 1) - require.Len(t, pkg.PostInstall[0].RunCommands, 2) - assert.Equal(t, "systemctl enable redis", pkg.PostInstall[0].RunCommands[0]) - assert.Equal(t, "systemctl start redis", pkg.PostInstall[0].RunCommands[1]) - }) -} - -func TestLoadPackages_CentOS7(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionCentOS, - DistributionVersion: "7", - }) - require.NoError(t, err) - require.NotEmpty(t, packages) - - t.Run("php with pre-install", func(t *testing.T) { - pkg, exists := packages["php"] - require.True(t, exists, "php should exist in centos_7.yaml") - assert.Equal(t, "php", pkg.Name) - assert.Equal(t, []string{"php-cli", "php-common", "php-fpm"}, pkg.ReplaceWith) - require.Len(t, pkg.PreInstall, 1) - require.Len(t, pkg.PreInstall[0].RunCommands, 3) - assert.Equal(t, "yum -y install https://rpms.remirepo.net/enterprise/remi-release-7.rpm", pkg.PreInstall[0].RunCommands[0]) - assert.Equal(t, "yum -y install yum-utils", pkg.PreInstall[0].RunCommands[1]) - assert.Equal(t, "yum-config-manager --enable remi-php82", pkg.PreInstall[0].RunCommands[2]) - }) - - t.Run("inherits from default.yaml", func(t *testing.T) { - pkg, exists := packages["lib32gcc"] - require.True(t, exists, "lib32gcc should be inherited from default.yaml") - assert.Equal(t, []string{"libgcc.i686"}, pkg.ReplaceWith) - }) -} - -func TestLoadPackages_CentOS8(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionCentOS, - DistributionVersion: "8", - }) - require.NoError(t, err) - require.NotEmpty(t, packages) - - t.Run("php with pre-install", func(t *testing.T) { - pkg, exists := packages["php"] - require.True(t, exists, "php should exist in centos_8.yaml") - assert.Equal(t, "php", pkg.Name) - assert.Equal(t, []string{"php-cli", "php-common", "php-fpm"}, pkg.ReplaceWith) - require.Len(t, pkg.PreInstall, 1) - require.Len(t, pkg.PreInstall[0].RunCommands, 2) - assert.Equal(t, "dnf -y install https://rpms.remirepo.net/enterprise/remi-release-8.rpm", pkg.PreInstall[0].RunCommands[0]) - assert.Equal(t, "dnf -y module switch-to php:remi-8.2", pkg.PreInstall[0].RunCommands[1]) - }) -} - -func TestLoadPackages_CentOS10(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionCentOS, - DistributionVersion: "10", - }) - require.NoError(t, err) - require.NotEmpty(t, packages) - - t.Run("redis-server with pre-install and post-install", func(t *testing.T) { - pkg, exists := packages["redis-server"] - require.True(t, exists, "redis-server should exist in centos_10.yaml") - assert.Equal(t, "redis-server", pkg.Name) - assert.Equal(t, []string{"redis"}, pkg.ReplaceWith) - - require.Len(t, pkg.PreInstall, 1) - require.Len(t, pkg.PreInstall[0].RunCommands, 2) - assert.Equal(t, "dnf install -y epel-release", pkg.PreInstall[0].RunCommands[0]) - assert.Equal(t, "dnf module enable redis:remi-7.2 -y", pkg.PreInstall[0].RunCommands[1]) - - require.Len(t, pkg.PostInstall, 1) - require.Len(t, pkg.PostInstall[0].RunCommands, 2) - assert.Equal(t, "systemctl enable redis", pkg.PostInstall[0].RunCommands[0]) - assert.Equal(t, "systemctl start redis", pkg.PostInstall[0].RunCommands[1]) - }) - - t.Run("postgresql overrides default", func(t *testing.T) { - pkg, exists := packages["postgresql"] - require.True(t, exists, "postgresql should exist") - assert.Equal(t, []string{"postgresql-server", "postgresql-contrib"}, pkg.ReplaceWith) - require.Len(t, pkg.PostInstall, 2) - require.Len(t, pkg.PostInstall[0].RunCommands, 3) - assert.Equal(t, "postgresql-setup --initdb", pkg.PostInstall[0].RunCommands[0]) - }) -} - -func TestLoadPackages_Rocky(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionRocky, - }) - require.NoError(t, err) - require.NotEmpty(t, packages) - - t.Run("lib32gcc overridden with empty array", func(t *testing.T) { - pkg, exists := packages["lib32gcc"] - require.True(t, exists, "lib32gcc should exist in rocky.yaml") - assert.Equal(t, "lib32gcc", pkg.Name) - assert.Empty(t, pkg.ReplaceWith, "rocky.yaml overrides with empty array") - }) - - t.Run("lib32stdc6 overridden with empty array", func(t *testing.T) { - pkg, exists := packages["lib32stdc6"] - require.True(t, exists, "lib32stdc6 should exist in rocky.yaml") - assert.Empty(t, pkg.ReplaceWith, "rocky.yaml overrides with empty array") - }) - - t.Run("lib32z1 overridden with empty array", func(t *testing.T) { - pkg, exists := packages["lib32z1"] - require.True(t, exists, "lib32z1 should exist in rocky.yaml") - assert.Empty(t, pkg.ReplaceWith, "rocky.yaml overrides with empty array") - }) - - t.Run("inherits packages not in rocky.yaml", func(t *testing.T) { - pkg, exists := packages["postgresql"] - require.True(t, exists, "postgresql should be inherited from default.yaml") - assert.Equal(t, []string{"postgresql-server", "postgresql-contrib"}, pkg.ReplaceWith) - }) -} - -func TestLoadPackages_MergeOverride(t *testing.T) { - t.Run("CentOS 10 redis-server overrides default", func(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionCentOS, - DistributionVersion: "10", - }) - require.NoError(t, err) - - pkg, exists := packages["redis-server"] - require.True(t, exists) - - require.Len(t, pkg.PreInstall, 1, "CentOS 10 should have pre-install steps") - require.Len(t, pkg.PreInstall[0].RunCommands, 2, "CentOS 10 should have pre-install commands") - assert.Contains(t, pkg.PreInstall[0].RunCommands[0], "epel-release") - - require.Len(t, pkg.PostInstall, 1, "CentOS 10 should override default post-install") - require.Len(t, pkg.PostInstall[0].RunCommands, 2, "CentOS 10 should have post-install commands") - }) -} - -func TestLoadPackages_NonExistentDistribution(t *testing.T) { - packages, err := LoadPackages(osinfo.Info{ - Distribution: "nonexistent", - DistributionVersion: "999", - }) - require.NoError(t, err) - require.NotEmpty(t, packages, "should still load default.yaml") - - _, exists := packages["lib32gcc"] - assert.True(t, exists, "should have default packages") -} - -func TestLoadPackages_CaseInsensitiveDistribution(t *testing.T) { - packagesLower, err := LoadPackages(osinfo.Info{ - Distribution: osinfo.DistributionCentOS, - DistributionVersion: "7", - }) - require.NoError(t, err) - - packagesUpper, err := LoadPackages(osinfo.Info{ - Distribution: "CentOS", - DistributionVersion: "7", - }) - require.NoError(t, err) - - assert.Equal(t, len(packagesLower), len(packagesUpper), "should load same files regardless of case") -} - -func Test_normalizeCommand(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "single line command", - input: "dnf install -y postgresql", - expected: "dnf install -y postgresql", - }, - { - name: "multiline command with newlines", - input: `systemctl enable postgresql && - systemctl start postgresql && - systemctl status postgresql`, - expected: `systemctl enable postgresql && systemctl start postgresql && systemctl status postgresql`, - }, - { - name: "multiple consecutive spaces", - input: "dnf install -y package", - expected: "dnf install -y package", - }, - { - name: "leading and trailing spaces", - input: " dnf update ", - expected: "dnf update", - }, - { - name: "tabs and spaces mixed", - input: "echo\t\ttest\tcommand", - expected: "echo test command", - }, - { - name: "windows line endings (CRLF)", - input: "line1\r\nline2\r\nline3", - expected: "line1 line2 line3", - }, - { - name: "unix line endings (LF)", - input: "line1\nline2\nline3", - expected: "line1 line2 line3", - }, - { - name: "old mac line endings (CR)", - input: "line1\rline2\rline3", - expected: "line1 line2 line3", - }, - { - name: "empty string", - input: "", - expected: "", - }, - { - name: "only whitespace", - input: " \n\t\r\n ", - expected: "", - }, - { - name: "real command from yaml", - input: `dnf -y install https://rpms.remirepo.net/enterprise/remi-release-8.rpm - && dnf -y module switch-to php:remi-8.2`, - expected: `dnf -y install https://rpms.remirepo.net/enterprise/remi-release-8.rpm && dnf -y module switch-to php:remi-8.2`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := normalizeCommand(tt.input) - assert.Equal(t, tt.expected, got) - }) - } -} diff --git a/pkg/package_manager/dnf_internal_test.go b/pkg/package_manager/dnf_internal_test.go index 56b821b..55cbb25 100644 --- a/pkg/package_manager/dnf_internal_test.go +++ b/pkg/package_manager/dnf_internal_test.go @@ -5,7 +5,7 @@ import ( "testing" osinfo "github.com/gameap/gameapctl/pkg/os_info" - pmdnf "github.com/gameap/gameapctl/pkg/package_manager/dnf" + "github.com/gameap/gameapctl/pkg/package_manager/pkgconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -71,8 +71,9 @@ Description : The mysql-common package provides the essential shared files for func Test_extendedDNF_preInstallationSteps(t *testing.T) { t.Run("no pre-install steps", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{ + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{ "test-package": { Name: "test-package", ReplaceWith: []string{"actual-package"}, @@ -86,8 +87,9 @@ func Test_extendedDNF_preInstallationSteps(t *testing.T) { }) t.Run("package not in config", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{}, + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{}, } packs, err := d.preInstallationSteps(context.Background(), []string{"unknown-package"}, &installOptions{}) @@ -96,11 +98,12 @@ func Test_extendedDNF_preInstallationSteps(t *testing.T) { }) t.Run("multiple packages with different configs", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{ + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{ "package-with-pre": { Name: "package-with-pre", - PreInstall: []pmdnf.PreInstallStep{ + PreInstall: []pkgconfig.PreInstallStep{ { RunCommands: []string{"echo pre-install"}, }, @@ -118,11 +121,12 @@ func Test_extendedDNF_preInstallationSteps(t *testing.T) { }) t.Run("duplicate packages in list", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{ + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{ "test-package": { Name: "test-package", - PreInstall: []pmdnf.PreInstallStep{ + PreInstall: []pkgconfig.PreInstallStep{ { RunCommands: []string{"echo test"}, }, @@ -139,8 +143,9 @@ func Test_extendedDNF_preInstallationSteps(t *testing.T) { func Test_extendedDNF_postInstallationSteps(t *testing.T) { t.Run("no post-install steps", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{ + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{ "test-package": { Name: "test-package", ReplaceWith: []string{"actual-package"}, @@ -153,8 +158,9 @@ func Test_extendedDNF_postInstallationSteps(t *testing.T) { }) t.Run("package not in config", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{}, + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{}, } err := d.postInstallationSteps(context.Background(), []string{"unknown-package"}, &installOptions{}) @@ -162,11 +168,12 @@ func Test_extendedDNF_postInstallationSteps(t *testing.T) { }) t.Run("multiple packages with different configs", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{ + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{ "package-with-post": { Name: "package-with-post", - PostInstall: []pmdnf.PostInstallStep{ + PostInstall: []pkgconfig.PostInstallStep{ { RunCommands: []string{"echo post-install"}, }, @@ -183,11 +190,12 @@ func Test_extendedDNF_postInstallationSteps(t *testing.T) { }) t.Run("duplicate packages in list", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{ + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{ "test-package": { Name: "test-package", - PostInstall: []pmdnf.PostInstallStep{ + PostInstall: []pkgconfig.PostInstallStep{ { RunCommands: []string{"echo test"}, }, @@ -202,7 +210,7 @@ func Test_extendedDNF_postInstallationSteps(t *testing.T) { } func Test_extendedDNF_executeCommand(t *testing.T) { - d := &extendedDNF{} + d := &extended{} t.Run("empty command", func(t *testing.T) { err := d.executeCommand(context.Background(), "") @@ -227,8 +235,9 @@ func Test_extendedDNF_executeCommand(t *testing.T) { func Test_extendedDNF_replaceAliases(t *testing.T) { t.Run("replace with configured packages", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{ + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{ "php": { Name: "php", ReplaceWith: []string{"php-cli", "php-common", "php-fpm"}, @@ -241,8 +250,9 @@ func Test_extendedDNF_replaceAliases(t *testing.T) { }) t.Run("keep unknown packages as is", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{}, + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{}, } result := d.replaceAliases(context.Background(), []string{"unknown-package"}) @@ -250,8 +260,9 @@ func Test_extendedDNF_replaceAliases(t *testing.T) { }) t.Run("mixed known and unknown packages", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{ + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{ "php": { Name: "php", ReplaceWith: []string{"php-cli", "php-fpm"}, @@ -264,8 +275,9 @@ func Test_extendedDNF_replaceAliases(t *testing.T) { }) t.Run("empty replace-with array", func(t *testing.T) { - d := &extendedDNF{ - packages: map[string]pmdnf.PackageConfig{ + d := &extended{ + strategy: noopStrategy{}, + packages: map[string]pkgconfig.PackageConfig{ "lib32gcc": { Name: "lib32gcc", ReplaceWith: []string{}, @@ -287,11 +299,11 @@ func Test_newExtendedDNF(t *testing.T) { } mockDNF := &dnf{} - extended, err := newExtendedDNF(osInfo, mockDNF) + ext, err := newExtendedDNF(osInfo, mockDNF) require.NoError(t, err) - require.NotNil(t, extended) - assert.NotNil(t, extended.packages) - assert.NotNil(t, extended.underlined) + require.NotNil(t, ext) + assert.NotNil(t, ext.packages) + assert.NotNil(t, ext.underlined) }) } diff --git a/pkg/package_manager/env_path_unix.go b/pkg/package_manager/env_path_unix.go index 8c55bb9..5fbd8a0 100644 --- a/pkg/package_manager/env_path_unix.go +++ b/pkg/package_manager/env_path_unix.go @@ -11,8 +11,7 @@ import ( internalcontext "github.com/gameap/gameapctl/internal/context" osinfo "github.com/gameap/gameapctl/pkg/os_info" - pmapt "github.com/gameap/gameapctl/pkg/package_manager/apt" - pmdnf "github.com/gameap/gameapctl/pkg/package_manager/dnf" + "github.com/gameap/gameapctl/pkg/package_manager/pkgconfig" "github.com/gameap/gameapctl/pkg/utils" "github.com/pkg/errors" ) @@ -42,7 +41,7 @@ func UpdateEnvPath(ctx context.Context) { } func collectAPTPathEnv(osinf osinfo.Info, currentPath, appendPath []string) []string { - packages, err := pmapt.LoadPackages(osinf) + packages, err := pkgconfig.LoadPackages("apt", osinf) if err != nil { return appendPath } @@ -55,7 +54,7 @@ func collectAPTPathEnv(osinf osinfo.Info, currentPath, appendPath []string) []st } func collectDNFPathEnv(osinf osinfo.Info, currentPath, appendPath []string) []string { - packages, err := pmdnf.LoadPackages(osinf) + packages, err := pkgconfig.LoadPackages("dnf", osinf) if err != nil { return appendPath } diff --git a/pkg/package_manager/extended.go b/pkg/package_manager/extended.go new file mode 100644 index 0000000..77c4fcc --- /dev/null +++ b/pkg/package_manager/extended.go @@ -0,0 +1,414 @@ +package packagemanager + +import ( + "bytes" + "context" + "log" + "os" + "os/exec" + "strings" + "text/template" + + "github.com/gameap/gameapctl/pkg/oscore" + "github.com/gameap/gameapctl/pkg/package_manager/pkgconfig" + "github.com/pkg/errors" + "github.com/samber/lo" +) + +// packageInstallStrategy encapsulates the package-manager-specific behaviour +// that differs between apt and dnf/yum. apt provides aptStrategy; dnf and yum +// use noopStrategy. +type packageInstallStrategy interface { + installDependencies(ctx context.Context, packs ...string) error + transformPreInstallPackages(ctx context.Context, packs []string) ([]string, error) + preRemove(ctx context.Context, packs ...string) error +} + +type noopStrategy struct{} + +func (noopStrategy) installDependencies(_ context.Context, _ ...string) error { + return nil +} + +func (noopStrategy) transformPreInstallPackages(_ context.Context, packs []string) ([]string, error) { + return packs, nil +} + +func (noopStrategy) preRemove(_ context.Context, _ ...string) error { + return nil +} + +// extended wraps a base PackageManager with YAML-driven pre/install/post +// steps shared by apt, dnf and yum. +type extended struct { + packages map[string]pkgconfig.PackageConfig + underlined PackageManager + strategy packageInstallStrategy +} + +func (e *extended) Search(ctx context.Context, name string) ([]PackageInfo, error) { + return e.underlined.Search(ctx, name) +} + +func (e *extended) Install(ctx context.Context, pack string, opts ...InstallOptions) error { + options := &installOptions{} + for _, opt := range opts { + opt(options) + } + + packs, err := e.excludeByLookupPathFound(ctx, pack) + if err != nil { + return errors.WithMessage(err, "failed to check lookup paths") + } + + if len(packs) == 0 { + return nil + } + + err = e.strategy.installDependencies(ctx, packs...) + if err != nil { + return errors.WithMessage(err, "failed to install dependencies") + } + + packs, err = e.preInstallationSteps(ctx, lo.Uniq(append(packs, pack)), options) + if err != nil { + return errors.WithMessage(err, "failed to run pre-installation steps") + } + + packs = e.replaceAliases(ctx, packs) + + packs, err = e.executeInstallSteps(ctx, packs, options) + if err != nil { + return errors.WithMessage(err, "failed to run install steps") + } + + for _, p := range packs { + err = e.underlined.Install(ctx, p, opts...) + if err != nil { + return errors.WithMessage(err, "failed to install packages") + } + } + + err = e.postInstallationSteps(ctx, lo.Uniq(append(packs, pack)), options) + if err != nil { + return errors.WithMessage(err, "failed to run post-installation steps") + } + + return nil +} + +func (e *extended) CheckForUpdates(ctx context.Context) error { + return e.underlined.CheckForUpdates(ctx) +} + +func (e *extended) Remove(ctx context.Context, packs ...string) error { + err := e.strategy.preRemove(ctx, packs...) + if err != nil { + return errors.WithMessage(err, "failed preRemovingSteps") + } + packs = e.replaceAliases(ctx, packs) + + return e.underlined.Remove(ctx, packs...) +} + +func (e *extended) Purge(ctx context.Context, packs ...string) error { + err := e.strategy.preRemove(ctx, packs...) + if err != nil { + return errors.WithMessage(err, "failed preRemovingSteps") + } + packs = e.replaceAliases(ctx, packs) + + return e.underlined.Purge(ctx, packs...) +} + +func (e *extended) replaceAliases(_ context.Context, packs []string) []string { + replacedPacks := make([]string, 0, len(packs)) + + for _, packName := range packs { + if pkgConfig, exists := e.packages[packName]; exists && pkgConfig.ReplaceWith != nil { + replacedPacks = append(replacedPacks, pkgConfig.ReplaceWith...) + } else { + replacedPacks = append(replacedPacks, packName) + } + } + + return replacedPacks +} + +//nolint:unparam +func (e *extended) excludeByLookupPathFound(_ context.Context, packs ...string) ([]string, error) { + filteredPacks := make([]string, 0, len(packs)) + + for _, packName := range packs { + config, exists := e.packages[packName] + if !exists || len(config.LookupPaths) == 0 { + filteredPacks = append(filteredPacks, packName) + + continue + } + + found := false + for _, lookupPath := range config.LookupPaths { + if _, err := exec.LookPath(lookupPath); err == nil { + found = true + + break + } + } + + if !found { + filteredPacks = append(filteredPacks, packName) + } + } + + return filteredPacks, nil +} + +func (e *extended) preInstallationSteps( + ctx context.Context, packs []string, options *installOptions, +) ([]string, error) { + err := e.executePreInstallationSteps(ctx, packs, options) + if err != nil { + return nil, errors.WithMessage(err, "failed to run pre-installation steps") + } + + return e.strategy.transformPreInstallPackages(ctx, packs) +} + +func (e *extended) executePreInstallationSteps(ctx context.Context, packs []string, options *installOptions) error { + executedPackages := make(map[string]bool) + + for _, packName := range packs { + config, exists := e.packages[packName] + if !exists { + continue + } + + if len(config.PreInstall) == 0 { + continue + } + + if executedPackages[packName] { + continue + } + + runtimeVars := extendedRuntimeTemplateVariables{ + LookupPaths: make(map[string]string, len(config.LookupPaths)), + Options: options, + } + + for _, lookupPath := range config.LookupPaths { + if resolvedPath, err := exec.LookPath(lookupPath); err == nil { + runtimeVars.LookupPaths[lookupPath] = resolvedPath + } + } + + for _, step := range config.PreInstall { + if !e.checkConditions(step.Conditions) { + continue + } + + for _, cmd := range step.RunCommands { + processedCmd, err := e.replaceRuntimeVariablesString(ctx, cmd, runtimeVars) + if err != nil { + return errors.WithMessagef( + err, + "failed to replace runtime variables in pre-install command for %s: %s", packName, cmd, + ) + } + + if err := e.executeCommand(ctx, processedCmd); err != nil { + return errors.WithMessagef( + err, + "failed to execute pre-install command for %s: %s", packName, processedCmd, + ) + } + } + } + + executedPackages[packName] = true + } + + return nil +} + +func (e *extended) executeInstallSteps( + ctx context.Context, packs []string, options *installOptions, +) ([]string, error) { + executedPackages := make(map[string]bool) + packsToInstall := make([]string, 0, len(packs)) + + for _, packName := range packs { + config, exists := e.packages[packName] + if !exists || len(config.Install) == 0 { + packsToInstall = append(packsToInstall, packName) + + continue + } + + if executedPackages[packName] { + continue + } + + runtimeVars := extendedRuntimeTemplateVariables{ + LookupPaths: make(map[string]string, len(config.LookupPaths)), + Options: options, + } + + for _, lookupPath := range config.LookupPaths { + if resolvedPath, err := exec.LookPath(lookupPath); err == nil { + runtimeVars.LookupPaths[lookupPath] = resolvedPath + } + } + + for _, step := range config.Install { + for _, cmd := range step.RunCommands { + processedCmd, err := e.replaceRuntimeVariablesString(ctx, cmd, runtimeVars) + if err != nil { + return nil, errors.WithMessagef( + err, + "failed to replace runtime variables in install command for %s: %s", packName, cmd, + ) + } + + if err := e.executeCommand(ctx, processedCmd); err != nil { + return nil, errors.WithMessagef( + err, + "failed to execute install command for %s: %s", packName, processedCmd, + ) + } + } + } + + executedPackages[packName] = true + } + + return packsToInstall, nil +} + +func (e *extended) postInstallationSteps(ctx context.Context, packs []string, options *installOptions) error { + executedPackages := make(map[string]bool) + + for _, packName := range packs { + config, exists := e.packages[packName] + if !exists { + continue + } + + if len(config.PostInstall) == 0 { + continue + } + + if executedPackages[packName] { + continue + } + + runtimeVars := extendedRuntimeTemplateVariables{ + LookupPaths: make(map[string]string, len(config.LookupPaths)), + Options: options, + } + + for _, lookupPath := range config.LookupPaths { + if resolvedPath, err := exec.LookPath(lookupPath); err == nil { + runtimeVars.LookupPaths[lookupPath] = resolvedPath + } + } + + for _, step := range config.PostInstall { + for _, cmd := range step.RunCommands { + processedCmd, err := e.replaceRuntimeVariablesString(ctx, cmd, runtimeVars) + if err != nil { + return errors.WithMessagef( + err, + "failed to replace runtime variables in post-install command for %s: %s", packName, cmd, + ) + } + + log.Println("Running post-install command:", processedCmd) + + if err := e.executeCommand(ctx, processedCmd); err != nil { + return errors.WithMessagef( + err, + "failed to execute post-install command for %s: %s", packName, processedCmd, + ) + } + } + } + + executedPackages[packName] = true + } + + return nil +} + +func (e *extended) checkConditions(conditions []pkgconfig.Condition) bool { + if len(conditions) == 0 { + return true + } + + for _, condition := range conditions { + if condition.FileNotExists != "" { + if _, err := os.Stat(condition.FileNotExists); err == nil { + return false + } + } + } + + return true +} + +func (e *extended) executeCommand(ctx context.Context, cmdStr string) error { + cmdStr = strings.TrimSpace(cmdStr) + if cmdStr == "" { + return nil + } + + command := "bash" + + if _, err := exec.LookPath(command); err != nil { + command = "sh" + } + + args := []string{"-c", cmdStr} + + return oscore.ExecCommand(ctx, command, args...) +} + +type extendedRuntimeTemplateVariables struct { + LookupPaths map[string]string + Options *installOptions +} + +func (e *extended) replaceRuntimeVariablesString( + _ context.Context, v string, vars extendedRuntimeTemplateVariables, +) (string, error) { + funcMap := template.FuncMap{ + "configValue": func(name string) string { + if vars.Options == nil { + return "" + } + + val, exists := vars.Options.configValues[name] + if !exists { + return "" + } + + return val + }, + } + + tmpl, err := template.New("package").Funcs(runtimeTemplateFuncMap).Funcs(funcMap).Parse(v) + if err != nil { + return "", errors.Wrap(err, "failed to parse template") + } + + var buf bytes.Buffer + buf.Grow(len(v) + 100) //nolint:mnd + + err = tmpl.Execute(&buf, vars) + if err != nil { + return "", errors.Wrap(err, "failed to execute template") + } + + return buf.String(), nil +} diff --git a/pkg/package_manager/manager.go b/pkg/package_manager/manager.go index e0e4989..37e08f2 100644 --- a/pkg/package_manager/manager.go +++ b/pkg/package_manager/manager.go @@ -59,6 +59,8 @@ func Load(ctx context.Context) (PackageManager, error) { return loadUbuntuPackageManager(ctx, osInfo) case DistributionCentOS: return loadCentOSPackageManager(ctx, osInfo) + case DistributionArch: + return loadArchPackageManager(ctx, osInfo) case DistributionWindows: return NewWindowsPackageManager(ctx, osInfo) } @@ -116,6 +118,14 @@ func loadCentOSPackageManager( return newExtendedDNF(osinfo, &yum{}) } +//nolint:ireturn,nolintlint +func loadArchPackageManager( + _ context.Context, + osinfo osinfo.Info, +) (PackageManager, error) { + return newExtendedPacman(osinfo, &pacman{}) +} + //nolint:ireturn,nolintlint func detectAndLoadPackageManager( _ context.Context, osinfo osinfo.Info, @@ -132,5 +142,9 @@ func detectAndLoadPackageManager( return newExtendedDNF(osinfo, &yum{}) } + if _, err := exec.LookPath("pacman"); err == nil { + return newExtendedPacman(osinfo, &pacman{}) + } + return nil, NewErrUnsupportedDistribution(string(osinfo.Distribution)) } diff --git a/pkg/package_manager/pacman.go b/pkg/package_manager/pacman.go new file mode 100644 index 0000000..6449d6e --- /dev/null +++ b/pkg/package_manager/pacman.go @@ -0,0 +1,148 @@ +package packagemanager + +import ( + "bufio" + "bytes" + "context" + "log" + "os" + "os/exec" + "strings" + + osinfo "github.com/gameap/gameapctl/pkg/os_info" + "github.com/gameap/gameapctl/pkg/package_manager/pkgconfig" + "github.com/pkg/errors" +) + +type pacman struct{} + +func (p *pacman) Search(_ context.Context, name string) ([]PackageInfo, error) { + cmd := exec.Command("pacman", "-Si", name) + cmd.Env = os.Environ() + + out, err := cmd.CombinedOutput() + log.Print(string(out)) + if err != nil { + if bytes.Contains(out, []byte("was not found")) { + return []PackageInfo{}, nil + } + + return nil, err + } + + return parsePacmanInfoOutput(out), nil +} + +func parsePacmanInfoOutput(out []byte) []PackageInfo { + scanner := bufio.NewScanner(bytes.NewReader(out)) + + var packages []PackageInfo + var currentPackage *PackageInfo + + for scanner.Scan() { + parts := strings.SplitN(scanner.Text(), ":", 2) + if len(parts) < 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "Name": + if currentPackage != nil { + packages = append(packages, *currentPackage) + } + currentPackage = &PackageInfo{} + + currentPackage.Name = value + case fieldVersion: + if currentPackage != nil { + currentPackage.Version = value + } + case fieldArchitecture: + if currentPackage != nil { + currentPackage.Architecture = value + } + case fieldDescription: + if currentPackage != nil { + currentPackage.Description = value + } + case "Download Size": + if currentPackage != nil { + currentPackage.Size = value + } + } + } + + if currentPackage != nil { + packages = append(packages, *currentPackage) + } + + return packages +} + +func (p *pacman) Install(_ context.Context, pack string, _ ...InstallOptions) error { + if pack == "" || pack == " " { + return nil + } + + args := []string{"-S", "--noconfirm", "--needed", pack} + cmd := exec.Command("pacman", args...) + + cmd.Env = os.Environ() + + log.Println("\n", cmd.String()) + cmd.Stderr = log.Writer() + cmd.Stdout = log.Writer() + + return cmd.Run() +} + +func (p *pacman) CheckForUpdates(_ context.Context) error { + cmd := exec.Command("pacman", "-Sy", "--noconfirm") + + cmd.Env = os.Environ() + + log.Println("\n", cmd.String()) + cmd.Stderr = log.Writer() + cmd.Stdout = log.Writer() + + return cmd.Run() +} + +func (p *pacman) Remove(_ context.Context, packs ...string) error { + args := []string{"-R", "--noconfirm"} + for _, pack := range packs { + if pack == "" || pack == " " { + continue + } + args = append(args, pack) + } + cmd := exec.Command("pacman", args...) + + cmd.Env = os.Environ() + + log.Println("\n", cmd.String()) + cmd.Stderr = log.Writer() + cmd.Stdout = log.Writer() + + return cmd.Run() +} + +func (p *pacman) Purge(ctx context.Context, packs ...string) error { + return p.Remove(ctx, packs...) +} + +func newExtendedPacman(osInfo osinfo.Info, underlined PackageManager) (*extended, error) { + packages, err := pkgconfig.LoadPackages("pacman", osInfo) + if err != nil { + return nil, errors.WithMessage(err, "failed to load pacman packages configuration") + } + + return &extended{ + packages: packages, + underlined: underlined, + strategy: noopStrategy{}, + }, nil +} diff --git a/pkg/package_manager/pacman_internal_test.go b/pkg/package_manager/pacman_internal_test.go new file mode 100644 index 0000000..d8d7b5d --- /dev/null +++ b/pkg/package_manager/pacman_internal_test.go @@ -0,0 +1,72 @@ +package packagemanager + +import ( + "testing" + + osinfo "github.com/gameap/gameapctl/pkg/os_info" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_parsePacmanInfoOutput(t *testing.T) { + out := []byte(` +Repository : multilib +Name : lib32-gcc-libs +Version : 14.2.1+r134+gab884fffe3fc-1 +Description : 32-bit runtime libraries shipped by GCC +Architecture : x86_64 +URL : https://gcc.gnu.org +Licenses : GPL-3.0-or-later LGPL-3.0-or-later +Download Size : 1.20 MiB +Installed Size : 3.49 MiB + +Repository : multilib +Name : lib32-zlib +Version : 1.3.1-1 +Description : Compression library implementing the deflate compression method +Architecture : x86_64 +URL : https://www.zlib.net/ +Download Size : 60.99 KiB +Installed Size : 168.00 KiB + + `) + + parsed := parsePacmanInfoOutput(out) + + require.Equal(t, []PackageInfo{ + { + Name: "lib32-gcc-libs", + Version: "14.2.1+r134+gab884fffe3fc-1", + Architecture: "x86_64", + Size: "1.20 MiB", + Description: "32-bit runtime libraries shipped by GCC", + }, + { + Name: "lib32-zlib", + Version: "1.3.1-1", + Architecture: "x86_64", + Size: "60.99 KiB", + Description: "Compression library implementing the deflate compression method", + }, + }, parsed) +} + +func Test_newExtendedPacman(t *testing.T) { + t.Run("creates extended pacman with packages", func(t *testing.T) { + osInfo := osinfo.Info{ + Distribution: osinfo.DistributionArch, + Platform: osinfo.PlatformAmd64, + } + + mockPacman := &pacman{} + ext, err := newExtendedPacman(osInfo, mockPacman) + + require.NoError(t, err) + require.NotNil(t, ext) + assert.NotNil(t, ext.packages) + assert.NotNil(t, ext.underlined) + + _, ok := ext.strategy.(noopStrategy) + assert.True(t, ok, "pacman must use noopStrategy") + }) +} diff --git a/pkg/package_manager/apt/README.md b/pkg/package_manager/pkgconfig/apt/README.md similarity index 100% rename from pkg/package_manager/apt/README.md rename to pkg/package_manager/pkgconfig/apt/README.md diff --git a/pkg/package_manager/apt/debian.yaml b/pkg/package_manager/pkgconfig/apt/debian.yaml similarity index 100% rename from pkg/package_manager/apt/debian.yaml rename to pkg/package_manager/pkgconfig/apt/debian.yaml diff --git a/pkg/package_manager/apt/debian_10.yaml b/pkg/package_manager/pkgconfig/apt/debian_10.yaml similarity index 100% rename from pkg/package_manager/apt/debian_10.yaml rename to pkg/package_manager/pkgconfig/apt/debian_10.yaml diff --git a/pkg/package_manager/apt/debian_11.yaml b/pkg/package_manager/pkgconfig/apt/debian_11.yaml similarity index 100% rename from pkg/package_manager/apt/debian_11.yaml rename to pkg/package_manager/pkgconfig/apt/debian_11.yaml diff --git a/pkg/package_manager/apt/debian_12.yaml b/pkg/package_manager/pkgconfig/apt/debian_12.yaml similarity index 100% rename from pkg/package_manager/apt/debian_12.yaml rename to pkg/package_manager/pkgconfig/apt/debian_12.yaml diff --git a/pkg/package_manager/apt/debian_13.yaml b/pkg/package_manager/pkgconfig/apt/debian_13.yaml similarity index 100% rename from pkg/package_manager/apt/debian_13.yaml rename to pkg/package_manager/pkgconfig/apt/debian_13.yaml diff --git a/pkg/package_manager/apt/debian_6.yaml b/pkg/package_manager/pkgconfig/apt/debian_6.yaml similarity index 100% rename from pkg/package_manager/apt/debian_6.yaml rename to pkg/package_manager/pkgconfig/apt/debian_6.yaml diff --git a/pkg/package_manager/apt/debian_7.yaml b/pkg/package_manager/pkgconfig/apt/debian_7.yaml similarity index 100% rename from pkg/package_manager/apt/debian_7.yaml rename to pkg/package_manager/pkgconfig/apt/debian_7.yaml diff --git a/pkg/package_manager/apt/debian_8.yaml b/pkg/package_manager/pkgconfig/apt/debian_8.yaml similarity index 100% rename from pkg/package_manager/apt/debian_8.yaml rename to pkg/package_manager/pkgconfig/apt/debian_8.yaml diff --git a/pkg/package_manager/apt/debian_9.yaml b/pkg/package_manager/pkgconfig/apt/debian_9.yaml similarity index 100% rename from pkg/package_manager/apt/debian_9.yaml rename to pkg/package_manager/pkgconfig/apt/debian_9.yaml diff --git a/pkg/package_manager/apt/debian_duke.yaml b/pkg/package_manager/pkgconfig/apt/debian_duke.yaml similarity index 100% rename from pkg/package_manager/apt/debian_duke.yaml rename to pkg/package_manager/pkgconfig/apt/debian_duke.yaml diff --git a/pkg/package_manager/apt/debian_forky.yaml b/pkg/package_manager/pkgconfig/apt/debian_forky.yaml similarity index 100% rename from pkg/package_manager/apt/debian_forky.yaml rename to pkg/package_manager/pkgconfig/apt/debian_forky.yaml diff --git a/pkg/package_manager/apt/debian_sid.yaml b/pkg/package_manager/pkgconfig/apt/debian_sid.yaml similarity index 100% rename from pkg/package_manager/apt/debian_sid.yaml rename to pkg/package_manager/pkgconfig/apt/debian_sid.yaml diff --git a/pkg/package_manager/apt/default.yaml b/pkg/package_manager/pkgconfig/apt/default.yaml similarity index 100% rename from pkg/package_manager/apt/default.yaml rename to pkg/package_manager/pkgconfig/apt/default.yaml diff --git a/pkg/package_manager/apt/default_arm64.yaml b/pkg/package_manager/pkgconfig/apt/default_arm64.yaml similarity index 100% rename from pkg/package_manager/apt/default_arm64.yaml rename to pkg/package_manager/pkgconfig/apt/default_arm64.yaml diff --git a/pkg/package_manager/apt/ubuntu.yaml b/pkg/package_manager/pkgconfig/apt/ubuntu.yaml similarity index 100% rename from pkg/package_manager/apt/ubuntu.yaml rename to pkg/package_manager/pkgconfig/apt/ubuntu.yaml diff --git a/pkg/package_manager/apt/ubuntu_12.04.yaml b/pkg/package_manager/pkgconfig/apt/ubuntu_12.04.yaml similarity index 100% rename from pkg/package_manager/apt/ubuntu_12.04.yaml rename to pkg/package_manager/pkgconfig/apt/ubuntu_12.04.yaml diff --git a/pkg/package_manager/apt/ubuntu_14.04.yaml b/pkg/package_manager/pkgconfig/apt/ubuntu_14.04.yaml similarity index 100% rename from pkg/package_manager/apt/ubuntu_14.04.yaml rename to pkg/package_manager/pkgconfig/apt/ubuntu_14.04.yaml diff --git a/pkg/package_manager/apt/ubuntu_16.04.yaml b/pkg/package_manager/pkgconfig/apt/ubuntu_16.04.yaml similarity index 100% rename from pkg/package_manager/apt/ubuntu_16.04.yaml rename to pkg/package_manager/pkgconfig/apt/ubuntu_16.04.yaml diff --git a/pkg/package_manager/apt/ubuntu_18.04.yaml b/pkg/package_manager/pkgconfig/apt/ubuntu_18.04.yaml similarity index 100% rename from pkg/package_manager/apt/ubuntu_18.04.yaml rename to pkg/package_manager/pkgconfig/apt/ubuntu_18.04.yaml diff --git a/pkg/package_manager/apt/ubuntu_20.04.yaml b/pkg/package_manager/pkgconfig/apt/ubuntu_20.04.yaml similarity index 100% rename from pkg/package_manager/apt/ubuntu_20.04.yaml rename to pkg/package_manager/pkgconfig/apt/ubuntu_20.04.yaml diff --git a/pkg/package_manager/apt/ubuntu_22.04.yaml b/pkg/package_manager/pkgconfig/apt/ubuntu_22.04.yaml similarity index 100% rename from pkg/package_manager/apt/ubuntu_22.04.yaml rename to pkg/package_manager/pkgconfig/apt/ubuntu_22.04.yaml diff --git a/pkg/package_manager/apt/ubuntu_22.10.yaml b/pkg/package_manager/pkgconfig/apt/ubuntu_22.10.yaml similarity index 100% rename from pkg/package_manager/apt/ubuntu_22.10.yaml rename to pkg/package_manager/pkgconfig/apt/ubuntu_22.10.yaml diff --git a/pkg/package_manager/apt/apt.go b/pkg/package_manager/pkgconfig/config.go similarity index 67% rename from pkg/package_manager/apt/apt.go rename to pkg/package_manager/pkgconfig/config.go index 80fd021..b2b3b0c 100644 --- a/pkg/package_manager/apt/apt.go +++ b/pkg/package_manager/pkgconfig/config.go @@ -1,4 +1,4 @@ -package apt +package pkgconfig import ( "embed" @@ -9,9 +9,10 @@ import ( osinfo "github.com/gameap/gameapctl/pkg/os_info" "github.com/goccy/go-yaml" + "github.com/pkg/errors" ) -//go:embed *.yaml +//go:embed apt dnf pacman var fs embed.FS type packagesConfig struct { @@ -51,9 +52,10 @@ var ( packageCacheMutex sync.RWMutex ) -func buildCacheKey(osinf osinfo.Info) string { +func buildCacheKey(manager string, osinf osinfo.Info) string { return fmt.Sprintf( - "%s_%s_%s_%s", + "%s_%s_%s_%s_%s", + manager, osinf.Distribution.String(), osinf.DistributionVersion, osinf.DistributionCodename, @@ -61,8 +63,13 @@ func buildCacheKey(osinf osinfo.Info) string { ) } -func LoadPackages(osinf osinfo.Info) (map[string]PackageConfig, error) { - cacheKey := buildCacheKey(osinf) +// LoadPackages reads the embedded package configuration for the given package +// manager ("apt", "dnf" or "pacman"), merging files from the most generic +// (default.yaml) to the most specific (distribution + codename/version + +// architecture). yum reuses the "dnf" configuration; the Arch family +// (Arch, CachyOS, Manjaro, EndeavourOS) uses the "pacman" configuration. +func LoadPackages(manager string, osinf osinfo.Info) (map[string]PackageConfig, error) { + cacheKey := buildCacheKey(manager, osinf) packageCacheMutex.RLock() if cached, exists := packageCache[cacheKey]; exists { @@ -80,7 +87,9 @@ func LoadPackages(osinf osinfo.Info) (map[string]PackageConfig, error) { } packages := make(map[string]PackageConfig) - distribution := osinf.Distribution.String() + distribution := strings.ToLower(osinf.Distribution.String()) + version := osinf.DistributionVersion + codename := osinf.DistributionCodename arch := osinf.Platform.String() filesToLoad := []string{ @@ -92,41 +101,29 @@ func LoadPackages(osinf osinfo.Info) (map[string]PackageConfig, error) { } if distribution != "" { - filesToLoad = append(filesToLoad, fmt.Sprintf("%s.yaml", strings.ToLower(distribution))) + filesToLoad = append(filesToLoad, fmt.Sprintf("%s.yaml", distribution)) } - if distribution != "" && osinf.DistributionVersion != "" { - filesToLoad = append( - filesToLoad, - fmt.Sprintf("%s_%s.yaml", strings.ToLower(distribution), osinf.DistributionVersion), - ) + if distribution != "" && version != "" { + filesToLoad = append(filesToLoad, fmt.Sprintf("%s_%s.yaml", distribution, version)) } - if distribution != "" && osinf.DistributionCodename != "" { - filesToLoad = append( - filesToLoad, - fmt.Sprintf("%s_%s.yaml", strings.ToLower(distribution), osinf.DistributionCodename), - ) + if distribution != "" && codename != "" { + filesToLoad = append(filesToLoad, fmt.Sprintf("%s_%s.yaml", distribution, codename)) } - if distribution != "" && osinf.DistributionVersion != "" && arch != "" { - filesToLoad = append( - filesToLoad, - fmt.Sprintf("%s_%s_%s.yaml", strings.ToLower(distribution), osinf.DistributionVersion, arch), - ) + if distribution != "" && version != "" && arch != "" { + filesToLoad = append(filesToLoad, fmt.Sprintf("%s_%s_%s.yaml", distribution, version, arch)) } - if distribution != "" && osinf.DistributionCodename != "" && arch != "" { - filesToLoad = append( - filesToLoad, - fmt.Sprintf("%s_%s_%s.yaml", strings.ToLower(distribution), osinf.DistributionCodename, arch), - ) + if distribution != "" && codename != "" && arch != "" { + filesToLoad = append(filesToLoad, fmt.Sprintf("%s_%s_%s.yaml", distribution, codename, arch)) } - log.Printf("Loading packages from files: %v", filesToLoad) + log.Printf("Loading %s packages from files: %v", manager, filesToLoad) for _, filename := range filesToLoad { - data, err := fs.ReadFile(filename) + data, err := fs.ReadFile(manager + "/" + filename) if err != nil { continue } @@ -134,7 +131,7 @@ func LoadPackages(osinf osinfo.Info) (map[string]PackageConfig, error) { var config packagesConfig err = yaml.Unmarshal(data, &config) if err != nil { - return nil, fmt.Errorf("failed to unmarshal %s: %w", filename, err) + return nil, errors.Wrapf(err, "failed to unmarshal %s", filename) } for _, pkg := range config.Packages { @@ -181,13 +178,13 @@ func replaceDistributionVariables(input string, osinf osinfo.Info) string { return result } -func normalizeCommandSlice(cmds []string) []string { - normalized := make([]string, len(cmds)) - for i, cmd := range cmds { - normalized[i] = normalizeCommand(cmd) +func normalizeCommandSlice(inputs []string) []string { + results := make([]string, len(inputs)) + for i, input := range inputs { + results[i] = normalizeCommand(input) } - return normalized + return results } func normalizeCommand(s string) string { diff --git a/pkg/package_manager/pkgconfig/config_test.go b/pkg/package_manager/pkgconfig/config_test.go new file mode 100644 index 0000000..77447b0 --- /dev/null +++ b/pkg/package_manager/pkgconfig/config_test.go @@ -0,0 +1,645 @@ +package pkgconfig + +import ( + "testing" + + osinfo "github.com/gameap/gameapctl/pkg/os_info" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadPackages_APT_Default(t *testing.T) { + packages, err := LoadPackages("apt", osinfo.Info{}) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("postgresql package", func(t *testing.T) { + pkg, exists := packages["postgresql"] + require.True(t, exists, "postgresql should exist in default.yaml") + assert.Equal(t, "postgresql", pkg.Name) + assert.Equal(t, []string{"postgresql", "postgresql-contrib"}, pkg.ReplaceWith) + assert.Empty(t, pkg.PreInstall) + require.Len(t, pkg.PostInstall, 2) + assert.Empty(t, pkg.Install) + }) + + t.Run("lib32gcc package", func(t *testing.T) { + pkg, exists := packages["lib32gcc"] + require.True(t, exists, "lib32gcc should exist in default.yaml") + assert.Equal(t, "lib32gcc", pkg.Name) + assert.Empty(t, pkg.ReplaceWith) + }) + + t.Run("composer package with install", func(t *testing.T) { + pkg, exists := packages["composer"] + require.True(t, exists, "composer should exist in default.yaml") + assert.Equal(t, "composer", pkg.Name) + require.Len(t, pkg.Install, 1) + require.Len(t, pkg.Install[0].RunCommands, 1) + assert.Contains(t, pkg.Install[0].RunCommands[0], "curl -sS https://getcomposer.org/installer") + assert.Contains(t, pkg.Install[0].RunCommands[0], "php -- --install-dir=/usr/local/bin --filename=composer") + }) + + t.Run("nodejs with pre-install", func(t *testing.T) { + pkg, exists := packages["nodejs"] + require.True(t, exists, "nodejs should exist in default.yaml") + assert.Equal(t, "nodejs", pkg.Name) + require.Len(t, pkg.PreInstall, 2) + require.Len(t, pkg.PreInstall[0].RunCommands, 2) + require.Len(t, pkg.PreInstall[1].RunCommands, 2) + assert.Equal(t, "mkdir -p /usr/share/keyrings/", pkg.PreInstall[0].RunCommands[0]) + assert.Contains(t, pkg.PreInstall[0].RunCommands[1], "curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key") + assert.Contains(t, pkg.PreInstall[0].RunCommands[1], "gpg --dearmor -o /usr/share/keyrings/nodesource.gpg") + assert.Contains(t, pkg.PreInstall[1].RunCommands[0], "deb [signed-by=/usr/share/keyrings/nodesource.gpg]") + assert.Contains(t, pkg.PreInstall[1].RunCommands[0], "https://deb.nodesource.com/node_24.x") + }) +} + +func TestLoadPackages_APT_ARM64(t *testing.T) { + packages, err := LoadPackages("apt", osinfo.Info{ + Platform: osinfo.PlatformArm64, + }) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("lib32gcc with arm64 replacement", func(t *testing.T) { + pkg, exists := packages["lib32gcc"] + require.True(t, exists, "lib32gcc should exist in default_arm64.yaml") + assert.Equal(t, "lib32gcc", pkg.Name) + assert.Equal(t, []string{"lib32gcc-s1-amd64-cross"}, pkg.ReplaceWith) + }) + + t.Run("lib32stdc++6 with arm64 replacement", func(t *testing.T) { + pkg, exists := packages["lib32stdc++6"] + require.True(t, exists, "lib32stdc++6 should exist in default_arm64.yaml") + assert.Equal(t, "lib32stdc++6", pkg.Name) + assert.Equal(t, []string{"lib32stdc++6-amd64-cross"}, pkg.ReplaceWith) + }) + + t.Run("lib32z1 with empty replacement", func(t *testing.T) { + pkg, exists := packages["lib32z1"] + require.True(t, exists, "lib32z1 should exist in default_arm64.yaml") + assert.Equal(t, "lib32z1", pkg.Name) + assert.Empty(t, pkg.ReplaceWith) + }) +} + +func TestLoadPackages_APT_Debian12(t *testing.T) { + packages, err := LoadPackages("apt", osinfo.Info{ + Distribution: osinfo.DistributionDebian, + DistributionVersion: "12", + }) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("mysql-server replaced with default-mysql-server", func(t *testing.T) { + pkg, exists := packages["mysql-server"] + require.True(t, exists, "mysql-server should exist in debian_12.yaml") + assert.Equal(t, "mysql-server", pkg.Name) + assert.Equal(t, []string{"default-mysql-server"}, pkg.ReplaceWith) + }) + + t.Run("lib32gcc replaced with lib32gcc-s1", func(t *testing.T) { + pkg, exists := packages["lib32gcc"] + require.True(t, exists, "lib32gcc should exist in debian_12.yaml") + assert.Equal(t, "lib32gcc", pkg.Name) + assert.Equal(t, []string{"lib32gcc-s1"}, pkg.ReplaceWith) + }) + + t.Run("nodejs replaced with nodejs and npm", func(t *testing.T) { + pkg, exists := packages["nodejs"] + require.True(t, exists, "nodejs should exist in debian_12.yaml") + assert.Equal(t, "nodejs", pkg.Name) + assert.Equal(t, []string{"nodejs", "npm"}, pkg.ReplaceWith) + }) +} + +func TestLoadPackages_APT_DebianSid(t *testing.T) { + packages, err := LoadPackages("apt", osinfo.Info{ + Distribution: osinfo.DistributionDebian, + DistributionCodename: "sid", + }) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("mysql-server replaced with default-mysql-server", func(t *testing.T) { + pkg, exists := packages["mysql-server"] + require.True(t, exists, "mysql-server should exist in debian_sid.yaml") + assert.Equal(t, "mysql-server", pkg.Name) + assert.Equal(t, []string{"default-mysql-server"}, pkg.ReplaceWith) + }) + + t.Run("lib32gcc replaced with lib32gcc-s1", func(t *testing.T) { + pkg, exists := packages["lib32gcc"] + require.True(t, exists, "lib32gcc should exist in debian_sid.yaml") + assert.Equal(t, "lib32gcc", pkg.Name) + assert.Equal(t, []string{"lib32gcc-s1"}, pkg.ReplaceWith) + }) +} + +func TestLoadPackages_APT_FileHierarchy(t *testing.T) { + t.Run("default overridden by arch-specific", func(t *testing.T) { + packages, err := LoadPackages("apt", osinfo.Info{ + Platform: osinfo.PlatformArm64, + }) + require.NoError(t, err) + + pkg, exists := packages["lib32gcc"] + require.True(t, exists) + assert.Equal(t, []string{"lib32gcc-s1-amd64-cross"}, pkg.ReplaceWith) + }) + + t.Run("default overridden by distribution version", func(t *testing.T) { + packages, err := LoadPackages("apt", osinfo.Info{ + Distribution: osinfo.DistributionDebian, + DistributionVersion: "12", + }) + require.NoError(t, err) + + pkg, exists := packages["lib32gcc"] + require.True(t, exists) + assert.Equal(t, []string{"lib32gcc-s1"}, pkg.ReplaceWith) + }) + + t.Run("default overridden by distribution codename", func(t *testing.T) { + packages, err := LoadPackages("apt", osinfo.Info{ + Distribution: osinfo.DistributionDebian, + DistributionCodename: "sid", + }) + require.NoError(t, err) + + pkg, exists := packages["lib32gcc"] + require.True(t, exists) + assert.Equal(t, []string{"lib32gcc-s1"}, pkg.ReplaceWith) + }) +} + +func TestLoadPackages_DNF_Default(t *testing.T) { + packages, err := LoadPackages("dnf", osinfo.Info{}) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("lib32gcc package", func(t *testing.T) { + pkg, exists := packages["lib32gcc"] + require.True(t, exists, "lib32gcc should exist in default.yaml") + assert.Equal(t, "lib32gcc", pkg.Name) + assert.Equal(t, []string{"libgcc.i686"}, pkg.ReplaceWith) + assert.Empty(t, pkg.PreInstall) + assert.Empty(t, pkg.PostInstall) + }) + + t.Run("lib32stdc6 package", func(t *testing.T) { + pkg, exists := packages["lib32stdc6"] + require.True(t, exists, "lib32stdc6 should exist in default.yaml") + assert.Equal(t, "lib32stdc6", pkg.Name) + assert.Equal(t, []string{"libstdc++.i686"}, pkg.ReplaceWith) + }) + + t.Run("xz-utils package", func(t *testing.T) { + pkg, exists := packages["xz-utils"] + require.True(t, exists, "xz-utils should exist in default.yaml") + assert.Equal(t, "xz-utils", pkg.Name) + assert.Equal(t, []string{"xz"}, pkg.ReplaceWith) + }) + + t.Run("php-extensions package", func(t *testing.T) { + pkg, exists := packages["php-extensions"] + require.True(t, exists, "php-extensions should exist in default.yaml") + assert.Equal(t, "php-extensions", pkg.Name) + assert.Contains(t, pkg.ReplaceWith, "php-bcmath") + assert.Contains(t, pkg.ReplaceWith, "php-gd") + assert.Contains(t, pkg.ReplaceWith, "php-xml") + }) + + t.Run("postgresql with post-install", func(t *testing.T) { + pkg, exists := packages["postgresql"] + require.True(t, exists, "postgresql should exist in default.yaml") + assert.Equal(t, "postgresql", pkg.Name) + assert.Equal(t, []string{"postgresql-server", "postgresql-contrib"}, pkg.ReplaceWith) + require.Len(t, pkg.PostInstall, 2) + require.Len(t, pkg.PostInstall[0].RunCommands, 3) + assert.Equal(t, "postgresql-setup --initdb", pkg.PostInstall[0].RunCommands[0]) + assert.Equal(t, "systemctl enable postgresql", pkg.PostInstall[0].RunCommands[1]) + assert.Equal(t, "systemctl start postgresql", pkg.PostInstall[0].RunCommands[2]) + }) + + t.Run("redis-server with post-install", func(t *testing.T) { + pkg, exists := packages["redis-server"] + require.True(t, exists, "redis-server should exist in default.yaml") + assert.Equal(t, "redis-server", pkg.Name) + assert.Equal(t, []string{"redis"}, pkg.ReplaceWith) + require.Len(t, pkg.PostInstall, 1) + require.Len(t, pkg.PostInstall[0].RunCommands, 2) + assert.Equal(t, "systemctl enable redis", pkg.PostInstall[0].RunCommands[0]) + assert.Equal(t, "systemctl start redis", pkg.PostInstall[0].RunCommands[1]) + }) +} + +func TestLoadPackages_Pacman_Default(t *testing.T) { + packages, err := LoadPackages("pacman", osinfo.Info{ + Distribution: osinfo.DistributionArch, + }) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("lib32gcc with multilib pre-install", func(t *testing.T) { + pkg, exists := packages["lib32gcc"] + require.True(t, exists, "lib32gcc should exist in default.yaml") + assert.Equal(t, "lib32gcc", pkg.Name) + assert.Equal(t, []string{"lib32-gcc-libs"}, pkg.ReplaceWith) + require.Len(t, pkg.PreInstall, 1) + require.Len(t, pkg.PreInstall[0].RunCommands, 2) + assert.Contains(t, pkg.PreInstall[0].RunCommands[0], "[multilib]") + assert.Contains(t, pkg.PreInstall[0].RunCommands[0], "/etc/pacman.conf") + assert.Equal(t, "pacman -Sy --noconfirm", pkg.PreInstall[0].RunCommands[1]) + }) + + t.Run("lib32stdc6 and lib32stdc++6 map to lib32-gcc-libs", func(t *testing.T) { + pkg, exists := packages["lib32stdc6"] + require.True(t, exists, "lib32stdc6 should exist in default.yaml") + assert.Equal(t, []string{"lib32-gcc-libs"}, pkg.ReplaceWith) + + pkg, exists = packages["lib32stdc++6"] + require.True(t, exists, "lib32stdc++6 should exist in default.yaml") + assert.Equal(t, []string{"lib32-gcc-libs"}, pkg.ReplaceWith) + }) + + t.Run("lib32z1 maps to lib32-zlib", func(t *testing.T) { + pkg, exists := packages["lib32z1"] + require.True(t, exists, "lib32z1 should exist in default.yaml") + assert.Equal(t, []string{"lib32-zlib"}, pkg.ReplaceWith) + require.Len(t, pkg.PreInstall, 1) + }) + + t.Run("xz-utils maps to xz", func(t *testing.T) { + pkg, exists := packages["xz-utils"] + require.True(t, exists, "xz-utils should exist in default.yaml") + assert.Equal(t, []string{"xz"}, pkg.ReplaceWith) + assert.Empty(t, pkg.PreInstall) + }) + + t.Run("postgresql with initdb post-install", func(t *testing.T) { + pkg, exists := packages["postgresql"] + require.True(t, exists, "postgresql should exist in default.yaml") + assert.Equal(t, []string{"postgresql"}, pkg.ReplaceWith) + require.Len(t, pkg.PostInstall, 2) + require.Len(t, pkg.PostInstall[0].RunCommands, 3) + assert.Contains(t, pkg.PostInstall[0].RunCommands[0], "PG_VERSION") + assert.Contains(t, pkg.PostInstall[0].RunCommands[0], "initdb") + assert.Equal(t, "systemctl enable postgresql", pkg.PostInstall[0].RunCommands[1]) + assert.Equal(t, "systemctl start postgresql", pkg.PostInstall[0].RunCommands[2]) + require.Len(t, pkg.PostInstall[1].RunCommands, 3) + assert.Contains(t, pkg.PostInstall[1].RunCommands[0], `configValue "db-root-password"`) + }) + + t.Run("redis-server maps to valkey", func(t *testing.T) { + pkg, exists := packages["redis-server"] + require.True(t, exists, "redis-server should exist in default.yaml") + assert.Equal(t, []string{"valkey"}, pkg.ReplaceWith) + require.Len(t, pkg.PostInstall, 1) + require.Len(t, pkg.PostInstall[0].RunCommands, 2) + assert.Equal(t, "systemctl enable valkey", pkg.PostInstall[0].RunCommands[0]) + assert.Equal(t, "systemctl start valkey", pkg.PostInstall[0].RunCommands[1]) + }) + + t.Run("docker native package with service", func(t *testing.T) { + pkg, exists := packages["docker"] + require.True(t, exists, "docker should exist in default.yaml") + assert.Equal(t, []string{"docker"}, pkg.ReplaceWith) + assert.Equal(t, []string{"docker"}, pkg.LookupPaths) + require.Len(t, pkg.PostInstall, 1) + assert.Equal(t, "systemctl enable docker", pkg.PostInstall[0].RunCommands[0]) + assert.Equal(t, "systemctl start docker", pkg.PostInstall[0].RunCommands[1]) + }) + + t.Run("go native package", func(t *testing.T) { + pkg, exists := packages["go"] + require.True(t, exists, "go should exist in default.yaml") + assert.Equal(t, []string{"go"}, pkg.ReplaceWith) + assert.Equal(t, []string{"go"}, pkg.LookupPaths) + }) + + t.Run("nodejs maps to nodejs and npm", func(t *testing.T) { + pkg, exists := packages["nodejs"] + require.True(t, exists, "nodejs should exist in default.yaml") + assert.Equal(t, []string{"nodejs", "npm"}, pkg.ReplaceWith) + assert.Equal(t, []string{"node", "npm"}, pkg.LookupPaths) + }) +} + +func TestLoadPackages_DNF_CentOS7(t *testing.T) { + packages, err := LoadPackages("dnf", osinfo.Info{ + Distribution: osinfo.DistributionCentOS, + DistributionVersion: "7", + }) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("php with pre-install", func(t *testing.T) { + pkg, exists := packages["php"] + require.True(t, exists, "php should exist in centos_7.yaml") + assert.Equal(t, "php", pkg.Name) + assert.Equal(t, []string{"php-cli", "php-common", "php-fpm"}, pkg.ReplaceWith) + require.Len(t, pkg.PreInstall, 1) + require.Len(t, pkg.PreInstall[0].RunCommands, 3) + assert.Equal(t, "yum -y install https://rpms.remirepo.net/enterprise/remi-release-7.rpm", pkg.PreInstall[0].RunCommands[0]) + assert.Equal(t, "yum -y install yum-utils", pkg.PreInstall[0].RunCommands[1]) + assert.Equal(t, "yum-config-manager --enable remi-php82", pkg.PreInstall[0].RunCommands[2]) + }) + + t.Run("inherits from default.yaml", func(t *testing.T) { + pkg, exists := packages["lib32gcc"] + require.True(t, exists, "lib32gcc should be inherited from default.yaml") + assert.Equal(t, []string{"libgcc.i686"}, pkg.ReplaceWith) + }) +} + +func TestLoadPackages_DNF_CentOS8(t *testing.T) { + packages, err := LoadPackages("dnf", osinfo.Info{ + Distribution: osinfo.DistributionCentOS, + DistributionVersion: "8", + }) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("php with pre-install", func(t *testing.T) { + pkg, exists := packages["php"] + require.True(t, exists, "php should exist in centos_8.yaml") + assert.Equal(t, "php", pkg.Name) + assert.Equal(t, []string{"php-cli", "php-common", "php-fpm"}, pkg.ReplaceWith) + require.Len(t, pkg.PreInstall, 1) + require.Len(t, pkg.PreInstall[0].RunCommands, 2) + assert.Equal(t, "dnf -y install https://rpms.remirepo.net/enterprise/remi-release-8.rpm", pkg.PreInstall[0].RunCommands[0]) + assert.Equal(t, "dnf -y module switch-to php:remi-8.2", pkg.PreInstall[0].RunCommands[1]) + }) +} + +func TestLoadPackages_DNF_CentOS10(t *testing.T) { + packages, err := LoadPackages("dnf", osinfo.Info{ + Distribution: osinfo.DistributionCentOS, + DistributionVersion: "10", + }) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("redis-server with pre-install and post-install", func(t *testing.T) { + pkg, exists := packages["redis-server"] + require.True(t, exists, "redis-server should exist in centos_10.yaml") + assert.Equal(t, "redis-server", pkg.Name) + assert.Equal(t, []string{"redis"}, pkg.ReplaceWith) + + require.Len(t, pkg.PreInstall, 1) + require.Len(t, pkg.PreInstall[0].RunCommands, 2) + assert.Equal(t, "dnf install -y epel-release", pkg.PreInstall[0].RunCommands[0]) + assert.Equal(t, "dnf module enable redis:remi-7.2 -y", pkg.PreInstall[0].RunCommands[1]) + + require.Len(t, pkg.PostInstall, 1) + require.Len(t, pkg.PostInstall[0].RunCommands, 2) + assert.Equal(t, "systemctl enable redis", pkg.PostInstall[0].RunCommands[0]) + assert.Equal(t, "systemctl start redis", pkg.PostInstall[0].RunCommands[1]) + }) + + t.Run("postgresql overrides default", func(t *testing.T) { + pkg, exists := packages["postgresql"] + require.True(t, exists, "postgresql should exist") + assert.Equal(t, []string{"postgresql-server", "postgresql-contrib"}, pkg.ReplaceWith) + require.Len(t, pkg.PostInstall, 2) + require.Len(t, pkg.PostInstall[0].RunCommands, 3) + assert.Equal(t, "postgresql-setup --initdb", pkg.PostInstall[0].RunCommands[0]) + }) +} + +func TestLoadPackages_DNF_Rocky(t *testing.T) { + packages, err := LoadPackages("dnf", osinfo.Info{ + Distribution: osinfo.DistributionRocky, + }) + require.NoError(t, err) + require.NotEmpty(t, packages) + + t.Run("lib32gcc overridden with empty array", func(t *testing.T) { + pkg, exists := packages["lib32gcc"] + require.True(t, exists, "lib32gcc should exist in rocky.yaml") + assert.Equal(t, "lib32gcc", pkg.Name) + assert.Empty(t, pkg.ReplaceWith, "rocky.yaml overrides with empty array") + }) + + t.Run("lib32stdc6 overridden with empty array", func(t *testing.T) { + pkg, exists := packages["lib32stdc6"] + require.True(t, exists, "lib32stdc6 should exist in rocky.yaml") + assert.Empty(t, pkg.ReplaceWith, "rocky.yaml overrides with empty array") + }) + + t.Run("lib32z1 overridden with empty array", func(t *testing.T) { + pkg, exists := packages["lib32z1"] + require.True(t, exists, "lib32z1 should exist in rocky.yaml") + assert.Empty(t, pkg.ReplaceWith, "rocky.yaml overrides with empty array") + }) + + t.Run("inherits packages not in rocky.yaml", func(t *testing.T) { + pkg, exists := packages["postgresql"] + require.True(t, exists, "postgresql should be inherited from default.yaml") + assert.Equal(t, []string{"postgresql-server", "postgresql-contrib"}, pkg.ReplaceWith) + }) +} + +func TestLoadPackages_DNF_MergeOverride(t *testing.T) { + t.Run("CentOS 10 redis-server overrides default", func(t *testing.T) { + packages, err := LoadPackages("dnf", osinfo.Info{ + Distribution: osinfo.DistributionCentOS, + DistributionVersion: "10", + }) + require.NoError(t, err) + + pkg, exists := packages["redis-server"] + require.True(t, exists) + + require.Len(t, pkg.PreInstall, 1, "CentOS 10 should have pre-install steps") + require.Len(t, pkg.PreInstall[0].RunCommands, 2, "CentOS 10 should have pre-install commands") + assert.Contains(t, pkg.PreInstall[0].RunCommands[0], "epel-release") + + require.Len(t, pkg.PostInstall, 1, "CentOS 10 should override default post-install") + require.Len(t, pkg.PostInstall[0].RunCommands, 2, "CentOS 10 should have post-install commands") + }) +} + +func TestLoadPackages_DNF_NonExistentDistribution(t *testing.T) { + packages, err := LoadPackages("dnf", osinfo.Info{ + Distribution: "nonexistent", + DistributionVersion: "999", + }) + require.NoError(t, err) + require.NotEmpty(t, packages, "should still load default.yaml") + + _, exists := packages["lib32gcc"] + assert.True(t, exists, "should have default packages") +} + +func TestLoadPackages_DNF_CaseInsensitiveDistribution(t *testing.T) { + packagesLower, err := LoadPackages("dnf", osinfo.Info{ + Distribution: osinfo.DistributionCentOS, + DistributionVersion: "7", + }) + require.NoError(t, err) + + packagesUpper, err := LoadPackages("dnf", osinfo.Info{ + Distribution: "CentOS", + DistributionVersion: "7", + }) + require.NoError(t, err) + + assert.Equal(t, len(packagesLower), len(packagesUpper), "should load same files regardless of case") +} + +func TestReplaceDistributionVariables(t *testing.T) { + osinf := osinfo.Info{ + Distribution: osinfo.DistributionDebian, + DistributionVersion: "12", + DistributionCodename: "bookworm", + Platform: osinfo.PlatformAmd64, + } + + t.Run("replace all variables", func(t *testing.T) { + input := "deb {{distname}} {{distversion}} {{codename}} {{architecture}}" + expected := "deb debian 12 bookworm amd64" + assert.Equal(t, expected, replaceDistributionVariables(input, osinf)) + }) + + t.Run("replace no variables", func(t *testing.T) { + input := "simple string" + expected := "simple string" + assert.Equal(t, expected, replaceDistributionVariables(input, osinf)) + }) + + t.Run("replace single variable", func(t *testing.T) { + input := "Distribution: {{distname}}" + expected := "Distribution: debian" + assert.Equal(t, expected, replaceDistributionVariables(input, osinf)) + }) + + t.Run("replace multiple occurrences", func(t *testing.T) { + input := "{{distname}} {{distname}} {{distname}}" + expected := "debian debian debian" //nolint:dupword + assert.Equal(t, expected, replaceDistributionVariables(input, osinf)) + }) +} + +func TestReplaceDistributionVariablesSlice(t *testing.T) { + osinf := osinfo.Info{ + Distribution: osinfo.DistributionDebian, + DistributionVersion: "12", + DistributionCodename: "bookworm", + Platform: osinfo.PlatformAmd64, + } + + t.Run("replace variables in slice", func(t *testing.T) { + inputs := []string{ + "deb {{distname}}", + "version {{distversion}}", + "codename {{codename}}", + "arch {{architecture}}", + } + expected := []string{ + "deb debian", + "version 12", + "codename bookworm", + "arch amd64", + } + result := replaceDistributionVariablesSlice(inputs, osinf) + assert.Equal(t, expected, result) + }) + + t.Run("empty slice", func(t *testing.T) { + inputs := []string{} + result := replaceDistributionVariablesSlice(inputs, osinf) + assert.Empty(t, result) + }) + + t.Run("slice with no variables", func(t *testing.T) { + inputs := []string{"foo", "bar", "baz"} + expected := []string{"foo", "bar", "baz"} + result := replaceDistributionVariablesSlice(inputs, osinf) + assert.Equal(t, expected, result) + }) +} + +func Test_normalizeCommand(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "single line command", + input: "apt-get install -y postgresql", + expected: "apt-get install -y postgresql", + }, + { + name: "multiline command with newlines", + input: `curl -fsSL https://example.com/key.gpg + | gpg --dearmor + -o /usr/share/keyrings/example.gpg`, + expected: `curl -fsSL https://example.com/key.gpg | gpg --dearmor -o /usr/share/keyrings/example.gpg`, + }, + { + name: "multiple consecutive spaces", + input: "apt-get install -y package", + expected: "apt-get install -y package", + }, + { + name: "leading and trailing spaces", + input: " apt-get update ", + expected: "apt-get update", + }, + { + name: "tabs and spaces mixed", + input: "echo\t\ttest\tcommand", + expected: "echo test command", + }, + { + name: "windows line endings (CRLF)", + input: "line1\r\nline2\r\nline3", + expected: "line1 line2 line3", + }, + { + name: "unix line endings (LF)", + input: "line1\nline2\nline3", + expected: "line1 line2 line3", + }, + { + name: "old mac line endings (CR)", + input: "line1\rline2\rline3", + expected: "line1 line2 line3", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "only whitespace", + input: " \n\t\r\n ", + expected: "", + }, + { + name: "real command from yaml", + input: `curl -sS https://getcomposer.org/installer + | php -- + --install-dir=/usr/local/bin + --filename=composer`, + expected: `curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer`, + }, + { + name: "real dnf command from yaml", + input: `dnf -y install https://rpms.remirepo.net/enterprise/remi-release-8.rpm + && dnf -y module switch-to php:remi-8.2`, + expected: `dnf -y install https://rpms.remirepo.net/enterprise/remi-release-8.rpm && dnf -y module switch-to php:remi-8.2`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeCommand(tt.input) + assert.Equal(t, tt.expected, got) + }) + } +} diff --git a/pkg/package_manager/dnf/README.md b/pkg/package_manager/pkgconfig/dnf/README.md similarity index 100% rename from pkg/package_manager/dnf/README.md rename to pkg/package_manager/pkgconfig/dnf/README.md diff --git a/pkg/package_manager/dnf/centos_10.yaml b/pkg/package_manager/pkgconfig/dnf/centos_10.yaml similarity index 100% rename from pkg/package_manager/dnf/centos_10.yaml rename to pkg/package_manager/pkgconfig/dnf/centos_10.yaml diff --git a/pkg/package_manager/dnf/centos_7.yaml b/pkg/package_manager/pkgconfig/dnf/centos_7.yaml similarity index 100% rename from pkg/package_manager/dnf/centos_7.yaml rename to pkg/package_manager/pkgconfig/dnf/centos_7.yaml diff --git a/pkg/package_manager/dnf/centos_8.yaml b/pkg/package_manager/pkgconfig/dnf/centos_8.yaml similarity index 100% rename from pkg/package_manager/dnf/centos_8.yaml rename to pkg/package_manager/pkgconfig/dnf/centos_8.yaml diff --git a/pkg/package_manager/dnf/default.yaml b/pkg/package_manager/pkgconfig/dnf/default.yaml similarity index 100% rename from pkg/package_manager/dnf/default.yaml rename to pkg/package_manager/pkgconfig/dnf/default.yaml diff --git a/pkg/package_manager/dnf/fedora.yaml b/pkg/package_manager/pkgconfig/dnf/fedora.yaml similarity index 100% rename from pkg/package_manager/dnf/fedora.yaml rename to pkg/package_manager/pkgconfig/dnf/fedora.yaml diff --git a/pkg/package_manager/dnf/rocky.yaml b/pkg/package_manager/pkgconfig/dnf/rocky.yaml similarity index 100% rename from pkg/package_manager/dnf/rocky.yaml rename to pkg/package_manager/pkgconfig/dnf/rocky.yaml diff --git a/pkg/package_manager/pkgconfig/pacman/README.md b/pkg/package_manager/pkgconfig/pacman/README.md new file mode 100644 index 0000000..bb153ec --- /dev/null +++ b/pkg/package_manager/pkgconfig/pacman/README.md @@ -0,0 +1,59 @@ +# Pacman Package Manager Configuration + +This directory contains package configuration files for the pacman package manager used on the Arch family of +distributions (Arch Linux, CachyOS, Manjaro, EndeavourOS). + +Routing: Arch Linux is detected via `osinfo.Distribution == "arch"`; other Arch derivatives (CachyOS, Manjaro, +EndeavourOS) fall through to detection by the presence of the `pacman` binary. In both cases the `pacman` +configuration in this directory is used. + +## Configuration Files Structure + +### `default.yaml` +Main configuration file for pacman package manager settings. +It includes package aliases and replacement definitions that apply across all Arch-based distributions. + +### `default_{architecture}.yaml` + +Architecture-specific overrides for the default configuration. + +Replace `architecture` with the system architecture (e.g., `amd64`, `arm64`). +These files provide additional customization for package management based on the system architecture. +Values in these files override those in `default.yaml` when applicable. + +For example: +- `default_amd64.yaml` for AMD64 architecture +- `default_arm64.yaml` for ARM64 architecture + +### `{distname}.yaml` + +Files are specific to each supported distribution. + +Replace `distname` with the name of the distribution (e.g., `arch`, `cachyos`, `manjaro`, `endeavouros`). +These files allow for customization of package management settings based on the specific OS. +Values in these files override those in `default.yaml` when applicable. + +For example: +- `arch.yaml` for Arch Linux +- `cachyos.yaml` for CachyOS + +### `{distname}_{distversion}_{architecture}.yaml` + +Architecture-specific configurations for specific distributions. + +Replace `architecture` with the system architecture (e.g., `amd64`, `arm64`). +These files provide further customization for package management based on the system architecture. +Values in these files override those in both `default.yaml` and `{distname}.yaml` when applicable. + +For example: +- `arch_amd64.yaml` for Arch Linux on AMD64 architecture + +## Notes + +- The `lib32-*` packages (e.g. `lib32-gcc-libs`, `lib32-zlib`) live in the `multilib` repository, which is + disabled by default on Arch. Their `pre-install` steps enable the `[multilib]` section in + `/etc/pacman.conf` idempotently and refresh the database (`pacman -Sy`). +- `redis-server` maps to `valkey`: Redis was removed from the official Arch repositories in April 2025 and + replaced by the drop-in-compatible `valkey` (service name `valkey`). +- `docker`, `go` and `nodejs` use native Arch packages instead of the upstream install scripts/tarballs used + on Debian/RHEL (the Docker convenience script does not support Arch; Arch's `go`/`nodejs` are current). diff --git a/pkg/package_manager/pkgconfig/pacman/default.yaml b/pkg/package_manager/pkgconfig/pacman/default.yaml new file mode 100644 index 0000000..5d1d15e --- /dev/null +++ b/pkg/package_manager/pkgconfig/pacman/default.yaml @@ -0,0 +1,66 @@ +packages: + - name: lib32gcc + replace-with: [lib32-gcc-libs] + pre-install: + - run-commands: + - grep -q '^\[multilib\]' /etc/pacman.conf || printf '\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n' >> /etc/pacman.conf + - pacman -Sy --noconfirm + + - name: lib32stdc6 + replace-with: [lib32-gcc-libs] + pre-install: + - run-commands: + - grep -q '^\[multilib\]' /etc/pacman.conf || printf '\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n' >> /etc/pacman.conf + - pacman -Sy --noconfirm + + - name: lib32stdc++6 + replace-with: [lib32-gcc-libs] + pre-install: + - run-commands: + - grep -q '^\[multilib\]' /etc/pacman.conf || printf '\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n' >> /etc/pacman.conf + - pacman -Sy --noconfirm + + - name: lib32z1 + replace-with: [lib32-zlib] + pre-install: + - run-commands: + - grep -q '^\[multilib\]' /etc/pacman.conf || printf '\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n' >> /etc/pacman.conf + - pacman -Sy --noconfirm + + - name: xz-utils + replace-with: [xz] + + - name: postgresql + replace-with: [postgresql] + post-install: + - run-commands: + - test -f /var/lib/postgres/data/PG_VERSION || su - postgres -c "initdb --locale=C.UTF-8 --encoding=UTF8 -D /var/lib/postgres/data" + - systemctl enable postgresql + - systemctl start postgresql + - run-commands: + - su - postgres -c "psql -c \"ALTER USER postgres WITH PASSWORD '{{ configValue "db-root-password" }}';\"" + - su - postgres -c "psql -c \"CREATE USER {{ configValue "db-user" }} WITH PASSWORD '{{ configValue "db-password" }}';\"" + - su - postgres -c "psql -c \"CREATE DATABASE {{ configValue "db-name" }} OWNER {{ configValue "db-user" }};\"" + + - name: redis-server + replace-with: [valkey] + post-install: + - run-commands: + - systemctl enable valkey + - systemctl start valkey + + - name: docker + replace-with: [docker] + lookup-paths: [ docker ] + post-install: + - run-commands: + - systemctl enable docker + - systemctl start docker + + - name: go + replace-with: [go] + lookup-paths: [ go ] + + - name: nodejs + replace-with: [nodejs, npm] + lookup-paths: [ node, npm ] diff --git a/pkg/package_manager/yum.go b/pkg/package_manager/yum.go index 4a96531..c15f685 100644 --- a/pkg/package_manager/yum.go +++ b/pkg/package_manager/yum.go @@ -52,13 +52,13 @@ func parseYumInfoOutput(out []byte) ([]PackageInfo, error) { currentPackage = &PackageInfo{} currentPackage.Name = value - case "Version": + case fieldVersion: currentPackage.Version = value - case "Architecture": + case fieldArchitecture: currentPackage.Architecture = value - case "Size": + case fieldSize: currentPackage.Size = value - case "Description": + case fieldDescription: currentPackage.Description = value case "": if value != "" && currentPackage != nil { diff --git a/pkg/service/service.go b/pkg/service/service.go index 3b2552d..a4e7358 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -75,7 +75,7 @@ func Status(ctx context.Context, serviceName string) error { } //nolint:ireturn,nolintlint -func Load(ctx context.Context) (srv Service, err error) { +func Load(ctx context.Context) (Service, error) { osInfo := contextInternal.OSInfoFromContext(ctx) once.Do(func() { @@ -85,33 +85,24 @@ func Load(ctx context.Context) (srv Service, err error) { return } - _, err = exec.LookPath("service") - if err == nil { - service = NewBasic() + if _, err := exec.LookPath("systemctl"); err == nil { + service = NewSystemd() return } - _, err := exec.LookPath("systemctl") - if err == nil { - service = NewSystemd() + if _, err := exec.LookPath("service"); err == nil { + service = NewBasic() return } }) - if err != nil { - return nil, err - } if service == nil { - err = NewErrUnsupportedDistribution(string(osInfo.Distribution)) - - return nil, err + return nil, NewErrUnsupportedDistribution(string(osInfo.Distribution)) } - srv = service - - return srv, nil + return service, nil } type Systemd struct{}