diff --git a/internal/packages/apk.go b/internal/packages/apk.go index babacdc..8641b8d 100644 --- a/internal/packages/apk.go +++ b/internal/packages/apk.go @@ -36,10 +36,10 @@ func (m *APKManager) GetPackages() []models.Package { m.logger.Debug("Getting installed packages...") installedCmd := exec.Command("apk", "list", "--installed") installedOutput, err := installedCmd.Output() - var installedPackages map[string]string + var installedPackages map[string]models.Package if err != nil { m.logger.WithError(err).Warn("Failed to get installed packages") - installedPackages = make(map[string]string) + installedPackages = make(map[string]models.Package) } else { m.logger.Debug("Parsing installed packages...") installedPackages = m.parseInstalledPackages(string(installedOutput)) @@ -69,9 +69,9 @@ func (m *APKManager) GetPackages() []models.Package { // parseInstalledPackages parses apk list --installed output // Format: package-name-version-release arch {origin} (license) [installed] -// Example: alpine-base-3.22.2-r0 x86_64 {alpine-base} (MIT) [installed] -func (m *APKManager) parseInstalledPackages(output string) map[string]string { - installedPackages := make(map[string]string) +// Example: alpine-base-3.22.2-r0 x86_64// parseInstalledPackages parses apk info -v output +func (m *APKManager) parseInstalledPackages(output string) map[string]models.Package { + installedPackages := make(map[string]models.Package) scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { @@ -97,15 +97,17 @@ func (m *APKManager) parseInstalledPackages(output string) map[string]string { packageWithVersion := fields[0] // Extract package name and version-release - // Format: package-name-version-release - // We need to find where the version starts (first dash followed by a digit) packageName, version := m.extractPackageNameAndVersion(packageWithVersion) if packageName == "" || version == "" { m.logger.WithField("line", line).Debug("Failed to extract package name or version") continue } - installedPackages[packageName] = version + installedPackages[packageName] = models.Package{ + Name: packageName, + CurrentVersion: version, + NeedsUpdate: false, + } } return installedPackages @@ -114,7 +116,7 @@ func (m *APKManager) parseInstalledPackages(output string) map[string]string { // parseUpgradablePackages parses apk -u list output // Format: package-name-new-version arch {origin} (license) [upgradable from: package-name-old-version] // Example: alpine-conf-3.20.0-r1 x86_64 {alpine-conf} (MIT) [upgradable from: alpine-conf-3.20.0-r0] -func (m *APKManager) parseUpgradablePackages(output string, installedPackages map[string]string) []models.Package { +func (m *APKManager) parseUpgradablePackages(output string, installedPackages map[string]models.Package) []models.Package { var packages []models.Package // Regex to match the upgradable from pattern @@ -167,8 +169,8 @@ func (m *APKManager) parseUpgradablePackages(output string, installedPackages ma // Use the current version from installed packages if available, otherwise use old version currentVersion := oldVersion - if installedVersion, found := installedPackages[newPackageName]; found { - currentVersion = installedVersion + if installedPkg, found := installedPackages[newPackageName]; found { + currentVersion = installedPkg.CurrentVersion } // Alpine doesn't have built-in security update tracking @@ -211,4 +213,3 @@ func (m *APKManager) extractPackageNameAndVersion(packageWithVersion string) (pa packageName = packageWithVersion return } - diff --git a/internal/packages/apt.go b/internal/packages/apt.go index f2e8e87..41c491a 100644 --- a/internal/packages/apt.go +++ b/internal/packages/apt.go @@ -48,12 +48,13 @@ func (m *APTManager) GetPackages() []models.Package { // Get installed packages m.logger.Debug("Getting installed packages...") - installedCmd := exec.Command("dpkg-query", "-W", "-f", "${Package} ${Version}\n") + // Note: Description can be multiline. Multiline descriptions in debian packages usually have subsequent lines indented. + installedCmd := exec.Command("dpkg-query", "-W", "-f", "${Package} ${Version} ${Description}\n") installedOutput, err := installedCmd.Output() - var installedPackages map[string]string + var installedPackages map[string]models.Package if err != nil { m.logger.WithError(err).Warn("Failed to get installed packages") - installedPackages = make(map[string]string) + installedPackages = make(map[string]models.Package) } else { m.logger.Debug("Parsing installed packages...") installedPackages = m.parseInstalledPackages(string(installedOutput)) @@ -152,25 +153,53 @@ func (m *APTManager) parseAPTUpgrade(output string) []models.Package { } // parseInstalledPackages parses dpkg-query output and returns a map of package name to version -func (m *APTManager) parseInstalledPackages(output string) map[string]string { - installedPackages := make(map[string]string) +func (m *APTManager) parseInstalledPackages(output string) map[string]models.Package { + installedPackages := make(map[string]models.Package) scanner := bufio.NewScanner(strings.NewReader(output)) + var currentPkg *models.Package + for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { + line := scanner.Text() // Preserve whitespace for description continuation detection + trimmedLine := strings.TrimSpace(line) + if trimmedLine == "" { + continue + } + + // Check if this line is a continuation of the description (starts with space) + if strings.HasPrefix(line, " ") && currentPkg != nil { + // It's a description continuation + // For now, we can append it or just skip if we only want the summary. + // Let's append it to have full description, joining with newline + currentPkg.Description += "\n" + trimmedLine + installedPackages[currentPkg.Name] = *currentPkg // Update map continue } - parts := strings.SplitN(line, " ", 2) - if len(parts) != 2 { + // New package line: Package Version Description + // We use SplitN with 3 parts. Description is the rest. + parts := strings.SplitN(trimmedLine, " ", 3) + if len(parts) < 2 { m.logger.WithField("line", line).Debug("Skipping malformed installed package line") + currentPkg = nil continue } packageName := parts[0] version := parts[1] - installedPackages[packageName] = version + description := "" + if len(parts) == 3 { + description = parts[2] + } + + pkg := models.Package{ + Name: packageName, + CurrentVersion: version, + Description: description, + NeedsUpdate: false, + } + installedPackages[packageName] = pkg + currentPkg = &pkg } return installedPackages diff --git a/internal/packages/apt_test.go b/internal/packages/apt_test.go index 8f081aa..25c6b8e 100644 --- a/internal/packages/apt_test.go +++ b/internal/packages/apt_test.go @@ -17,32 +17,48 @@ func TestAPTManager_parseInstalledPackages(t *testing.T) { tests := []struct { name string input string - expected map[string]string + expected map[string]models.Package }{ { name: "valid single package", - input: `vim 2:8.2.3995-1ubuntu2.17 + input: `vim 2:8.2.3995-1ubuntu2.17 Vi IMproved - enhanced vi editor `, - expected: map[string]string{ - "vim": "2:8.2.3995-1ubuntu2.17", + expected: map[string]models.Package{ + "vim": { + Name: "vim", + CurrentVersion: "2:8.2.3995-1ubuntu2.17", + Description: "Vi IMproved - enhanced vi editor", + }, }, }, { name: "multiple packages", - input: `vim 2:8.2.3995-1ubuntu2.17 -libc6 2.35-0ubuntu3.8 -bash 5.1-6ubuntu1.1 + input: `vim 2:8.2.3995-1ubuntu2.17 Vi IMproved +libc6 2.35-0ubuntu3.8 GNU C Library +bash 5.1-6ubuntu1.1 GNU Bourne Again SHell `, - expected: map[string]string{ - "vim": "2:8.2.3995-1ubuntu2.17", - "libc6": "2.35-0ubuntu3.8", - "bash": "5.1-6ubuntu1.1", + expected: map[string]models.Package{ + "vim": { + Name: "vim", + CurrentVersion: "2:8.2.3995-1ubuntu2.17", + Description: "Vi IMproved", + }, + "libc6": { + Name: "libc6", + CurrentVersion: "2.35-0ubuntu3.8", + Description: "GNU C Library", + }, + "bash": { + Name: "bash", + CurrentVersion: "5.1-6ubuntu1.1", + Description: "GNU Bourne Again SHell", + }, }, }, { name: "empty input", input: "", - expected: map[string]string{}, + expected: map[string]models.Package{}, }, } diff --git a/internal/packages/dnf.go b/internal/packages/dnf.go index 7af6db5..39782f0 100644 --- a/internal/packages/dnf.go +++ b/internal/packages/dnf.go @@ -50,15 +50,15 @@ func (m *DNFManager) GetPackages() []models.Package { // Get installed packages m.logger.Debug("Getting installed packages...") listCmd := exec.Command(packageManager, "list", "--installed") - listOutput, err := listCmd.Output() - var installedPackages map[string]string + installedOutput, err := listCmd.Output() + var installedPackages map[string]models.Package if err != nil { - m.logger.WithError(err).Error("Failed to get installed packages - this will result in 0 packages being reported") - installedPackages = make(map[string]string) + m.logger.WithError(err).Warn("Failed to get installed packages") + installedPackages = make(map[string]models.Package) } else { - m.logger.WithField("outputSize", len(listOutput)).Debug("Received output from list installed command") + m.logger.WithField("outputSize", len(installedOutput)).Debug("Received output from list installed command") m.logger.Debug("Parsing installed packages...") - installedPackages = m.parseInstalledPackages(string(listOutput)) + installedPackages = m.parseInstalledPackages(string(installedOutput)) m.logger.WithField("count", len(installedPackages)).Info("Found installed packages") if len(installedPackages) == 0 { @@ -205,7 +205,7 @@ func (m *DNFManager) extractBasePackageName(packageString string) string { } // parseUpgradablePackages parses dnf/yum check-update output -func (m *DNFManager) parseUpgradablePackages(output string, packageManager string, installedPackages map[string]string, securityPackages map[string]bool) []models.Package { +func (m *DNFManager) parseUpgradablePackages(output string, packageManager string, installedPackages map[string]models.Package, securityPackages map[string]bool) []models.Package { var packages []models.Package scanner := bufio.NewScanner(strings.NewReader(output)) @@ -228,7 +228,10 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin // Get current version from installed packages map (already collected) // Try exact match first - currentVersion := installedPackages[packageName] + var currentVersion string + if p, ok := installedPackages[packageName]; ok { + currentVersion = p.CurrentVersion + } // If not found, try to find by base name (handles architecture suffixes) // e.g., if packageName is "package" but installed has "package.x86_64" @@ -241,13 +244,15 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin if archSuffix == "x86_64" || archSuffix == "i686" || archSuffix == "i386" || archSuffix == "noarch" || archSuffix == "aarch64" || archSuffix == "arm64" { basePackageName = packageName[:idx] - currentVersion = installedPackages[basePackageName] + if p, ok := installedPackages[basePackageName]; ok { + currentVersion = p.CurrentVersion + } } } // If still not found, search through installed packages for matching base name if currentVersion == "" { - for installedName, version := range installedPackages { + for installedName, p := range installedPackages { // Remove architecture suffix if present (e.g., .x86_64, .noarch, .i686) baseName := installedName if idx := strings.LastIndex(installedName, "."); idx > 0 { @@ -261,7 +266,7 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin // Compare base names (handles both cases: package vs package.x86_64) if baseName == basePackageName || baseName == packageName { - currentVersion = version + currentVersion = p.CurrentVersion break } } @@ -273,7 +278,7 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin getCurrentCmd := exec.Command(packageManager, "list", "--installed", packageName) getCurrentOutput, err := getCurrentCmd.Output() if err == nil { - for currentLine := range strings.SplitSeq(string(getCurrentOutput), "\n") { + for _, currentLine := range strings.Split(string(getCurrentOutput), "\n") { if strings.Contains(currentLine, packageName) && !strings.Contains(currentLine, "Installed") && !strings.Contains(currentLine, "Available") { currentFields := slices.Collect(strings.FieldsSeq(currentLine)) if len(currentFields) >= 2 { @@ -311,28 +316,30 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin return packages } -// parseInstalledPackages parses dnf/yum list installed output and returns a map of package name to version -func (m *DNFManager) parseInstalledPackages(output string) map[string]string { - installedPackages := make(map[string]string) +// parseInstalledPackages parses dnf list installed output +func (m *DNFManager) parseInstalledPackages(output string) map[string]models.Package { + installedPackages := make(map[string]models.Package) scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) - - // Skip header lines and empty lines - if line == "" || strings.Contains(line, "Loaded plugins") || - strings.Contains(line, "Installed Packages") { + if line == "" || strings.HasPrefix(line, "Installed Packages") { continue } - fields := slices.Collect(strings.FieldsSeq(line)) - if len(fields) < 2 { + parts := strings.Fields(line) + if len(parts) < 3 { continue } - packageName := fields[0] - version := fields[1] - installedPackages[packageName] = version + packageName := strings.Split(parts[0], ".")[0] // Remove architecture + version := parts[1] + + installedPackages[packageName] = models.Package{ + Name: packageName, + CurrentVersion: version, + NeedsUpdate: false, + } } return installedPackages diff --git a/internal/packages/dnf_test.go b/internal/packages/dnf_test.go index a212699..568a4bb 100644 --- a/internal/packages/dnf_test.go +++ b/internal/packages/dnf_test.go @@ -1,6 +1,7 @@ package packages import ( + "patchmon-agent/pkg/models" "testing" "github.com/sirupsen/logrus" @@ -15,22 +16,30 @@ func TestDNFManager_parseInstalledPackages(t *testing.T) { tests := []struct { name string input string - expected map[string]string + expected map[string]models.Package }{ { name: "valid packages", input: `Installed Packages vim-enhanced.x86_64 2:8.2.2637-20.el9_1 @baseos bash.x86_64 5.1.8-6.el9_1 @baseos`, - expected: map[string]string{ - "vim-enhanced.x86_64": "2:8.2.2637-20.el9_1", - "bash.x86_64": "5.1.8-6.el9_1", + expected: map[string]models.Package{ + "vim-enhanced": { + Name: "vim-enhanced", + CurrentVersion: "2:8.2.2637-20.el9_1", + NeedsUpdate: false, + }, + "bash": { + Name: "bash", + CurrentVersion: "5.1.8-6.el9_1", + NeedsUpdate: false, + }, }, }, { name: "empty input", input: "", - expected: map[string]string{}, + expected: map[string]models.Package{}, }, } @@ -48,22 +57,28 @@ func TestDNFManager_parseUpgradablePackages(t *testing.T) { manager := NewDNFManager(logger) tests := []struct { - name string - input string - pkgMgr string - installedPackages map[string]string - securityPackages map[string]bool - expected int - expectedSecurity int + name string + input string + pkgMgr string + installedPackages map[string]models.Package + securityPackages map[string]bool + expected int + expectedSecurity int }{ { name: "upgradable packages", input: `kernel.x86_64 5.14.0-284.30.1.el9_2 baseos systemd.x86_64 252-14.el9_2.2 baseos`, pkgMgr: "dnf", - installedPackages: map[string]string{ - "kernel.x86_64": "5.14.0-284.30.1.el9_1", - "systemd.x86_64": "252-14.el9_2.1", + installedPackages: map[string]models.Package{ + "kernel.x86_64": { + Name: "kernel.x86_64", + CurrentVersion: "5.14.0-284.30.1.el9_1", + }, + "systemd.x86_64": { + Name: "systemd.x86_64", + CurrentVersion: "252-14.el9_2.1", + }, }, securityPackages: map[string]bool{ "kernel": true, diff --git a/internal/packages/packages.go b/internal/packages/packages.go index 87552bc..3d642b1 100644 --- a/internal/packages/packages.go +++ b/internal/packages/packages.go @@ -76,22 +76,28 @@ func (m *Manager) detectPackageManager() string { } // CombinePackageData combines and deduplicates installed and upgradable package lists -func CombinePackageData(installedPackages map[string]string, upgradablePackages []models.Package) []models.Package { +func CombinePackageData(installedPackages map[string]models.Package, upgradablePackages []models.Package) []models.Package { packages := make([]models.Package, 0) upgradableMap := make(map[string]bool) // First, add all upgradable packages for _, pkg := range upgradablePackages { + // Preserve description from installed packages if available and not present in upgradable + if installedPkg, exists := installedPackages[pkg.Name]; exists { + if pkg.Description == "" { + pkg.Description = installedPkg.Description + } + } packages = append(packages, pkg) upgradableMap[pkg.Name] = true } // Then add installed packages that are not upgradable - for packageName, version := range installedPackages { + for packageName, pkg := range installedPackages { if !upgradableMap[packageName] { packages = append(packages, models.Package{ - Name: packageName, - CurrentVersion: version, + Name: pkg.Name, + CurrentVersion: pkg.CurrentVersion, NeedsUpdate: false, IsSecurityUpdate: false, }) diff --git a/internal/packages/packages_test.go b/internal/packages/packages_test.go index 0e16ad9..e5b2e9c 100644 --- a/internal/packages/packages_test.go +++ b/internal/packages/packages_test.go @@ -11,17 +11,26 @@ import ( func TestCombinePackageData(t *testing.T) { tests := []struct { name string - installedPackages map[string]string + installedPackages map[string]models.Package upgradablePackages []models.Package expectedCount int expectedUpgradable int }{ { name: "merge installed and upgradable", - installedPackages: map[string]string{ - "vim": "2:8.2.3995-1ubuntu2.16", - "bash": "5.1-6ubuntu1", - "curl": "7.81.0-1ubuntu1.15", + installedPackages: map[string]models.Package{ + "vim": { + Name: "vim", + CurrentVersion: "2:8.2.3995-1ubuntu2.16", + }, + "bash": { + Name: "bash", + CurrentVersion: "5.1-6ubuntu1", + }, + "curl": { + Name: "curl", + CurrentVersion: "7.81.0-1ubuntu1.15", + }, }, upgradablePackages: []models.Package{ { @@ -36,7 +45,7 @@ func TestCombinePackageData(t *testing.T) { }, { name: "empty inputs", - installedPackages: map[string]string{}, + installedPackages: map[string]models.Package{}, upgradablePackages: []models.Package{}, expectedCount: 0, expectedUpgradable: 0, diff --git a/internal/version/version.go b/internal/version/version.go index 847c375..504fe5f 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,4 +1,4 @@ package version // Version represents the current version of the patchmon-agent -const Version = "1.3.7" +const Version = "1.4.0" diff --git a/pkg/models/models.go b/pkg/models/models.go index 37bf61b..a411235 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -3,6 +3,7 @@ package models // Package represents a software package type Package struct { Name string `json:"name"` + Description string `json:"description,omitempty"` CurrentVersion string `json:"currentVersion"` AvailableVersion string `json:"availableVersion,omitempty"` NeedsUpdate bool `json:"needsUpdate"`