Skip to content

Commit baf0ff8

Browse files
Merge pull request #22 from PatchMon/feature/alpine
Feature/alpine + 1.3.4
2 parents 70e0f33 + 752f115 commit baf0ff8

File tree

7 files changed

+475
-5
lines changed

7 files changed

+475
-5
lines changed

cmd/patchmon-agent/commands/report.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ func sendReport() error {
8989
if err != nil {
9090
return fmt.Errorf("failed to get packages: %w", err)
9191
}
92+
// Ensure packageList is never nil (should be empty slice, not nil)
93+
if packageList == nil {
94+
packageList = []models.Package{}
95+
}
9296

9397
// Count packages for debug logging
9498
needsUpdateCount := 0

internal/constants/constants.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const (
2828
ArchAMD64 = "amd64"
2929
ArchARM64 = "arm64"
3030
ArchAARCH64 = "aarch64"
31-
ArchUnknown = "unknown"
31+
ArchUnknown = "arch_unknown"
3232
)
3333

3434
// Network interface types
@@ -50,6 +50,7 @@ const (
5050
RepoTypeDeb = "deb"
5151
RepoTypeDebSrc = "deb-src"
5252
RepoTypeRPM = "rpm"
53+
RepoTypeAPK = "apk"
5354
)
5455

5556
// Log level constants

internal/packages/apk.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package packages
2+
3+
import (
4+
"bufio"
5+
"os/exec"
6+
"regexp"
7+
"strings"
8+
9+
"patchmon-agent/pkg/models"
10+
11+
"github.com/sirupsen/logrus"
12+
)
13+
14+
// APKManager handles APK package information collection
15+
type APKManager struct {
16+
logger *logrus.Logger
17+
}
18+
19+
// NewAPKManager creates a new APK package manager
20+
func NewAPKManager(logger *logrus.Logger) *APKManager {
21+
return &APKManager{
22+
logger: logger,
23+
}
24+
}
25+
26+
// GetPackages gets package information for APK-based systems
27+
func (m *APKManager) GetPackages() []models.Package {
28+
// Update package index
29+
m.logger.Debug("Updating package index...")
30+
updateCmd := exec.Command("apk", "update", "-q")
31+
if err := updateCmd.Run(); err != nil {
32+
m.logger.WithError(err).Warn("Failed to update package index")
33+
}
34+
35+
// Get installed packages
36+
m.logger.Debug("Getting installed packages...")
37+
installedCmd := exec.Command("apk", "list", "--installed")
38+
installedOutput, err := installedCmd.Output()
39+
var installedPackages map[string]string
40+
if err != nil {
41+
m.logger.WithError(err).Warn("Failed to get installed packages")
42+
installedPackages = make(map[string]string)
43+
} else {
44+
m.logger.Debug("Parsing installed packages...")
45+
installedPackages = m.parseInstalledPackages(string(installedOutput))
46+
m.logger.WithField("count", len(installedPackages)).Debug("Found installed packages")
47+
}
48+
49+
// Get upgradable packages (must run after apk update)
50+
m.logger.Debug("Getting upgradable packages...")
51+
upgradableCmd := exec.Command("apk", "-u", "list")
52+
upgradableOutput, err := upgradableCmd.Output()
53+
var upgradablePackages []models.Package
54+
if err != nil {
55+
m.logger.WithError(err).Warn("Failed to get upgradable packages")
56+
upgradablePackages = []models.Package{}
57+
} else {
58+
m.logger.Debug("Parsing apk upgradable packages output...")
59+
upgradablePackages = m.parseUpgradablePackages(string(upgradableOutput), installedPackages)
60+
m.logger.WithField("count", len(upgradablePackages)).Debug("Found upgradable packages")
61+
}
62+
63+
// Merge and deduplicate packages
64+
packages := CombinePackageData(installedPackages, upgradablePackages)
65+
m.logger.WithField("total", len(packages)).Debug("Total packages collected")
66+
67+
return packages
68+
}
69+
70+
// parseInstalledPackages parses apk list --installed output
71+
// Format: package-name-version-release arch {origin} (license) [installed]
72+
// Example: alpine-base-3.22.2-r0 x86_64 {alpine-base} (MIT) [installed]
73+
func (m *APKManager) parseInstalledPackages(output string) map[string]string {
74+
installedPackages := make(map[string]string)
75+
76+
scanner := bufio.NewScanner(strings.NewReader(output))
77+
for scanner.Scan() {
78+
line := strings.TrimSpace(scanner.Text())
79+
if line == "" {
80+
continue
81+
}
82+
83+
// Skip lines that don't have [installed] marker (shouldn't happen with --installed flag, but be safe)
84+
if !strings.Contains(line, "[installed]") {
85+
continue
86+
}
87+
88+
// Parse the line: package-name-version-release arch {origin} (license) [installed]
89+
// Example: alpine-base-3.22.2-r0 x86_64 {alpine-base} (MIT) [installed]
90+
fields := strings.Fields(line)
91+
if len(fields) < 2 {
92+
m.logger.WithField("line", line).Debug("Skipping malformed installed package line")
93+
continue
94+
}
95+
96+
// First field contains package-name-version-release
97+
packageWithVersion := fields[0]
98+
99+
// Extract package name and version-release
100+
// Format: package-name-version-release
101+
// We need to find where the version starts (first dash followed by a digit)
102+
packageName, version := m.extractPackageNameAndVersion(packageWithVersion)
103+
if packageName == "" || version == "" {
104+
m.logger.WithField("line", line).Debug("Failed to extract package name or version")
105+
continue
106+
}
107+
108+
installedPackages[packageName] = version
109+
}
110+
111+
return installedPackages
112+
}
113+
114+
// parseUpgradablePackages parses apk -u list output
115+
// Format: package-name-new-version arch {origin} (license) [upgradable from: package-name-old-version]
116+
// Example: alpine-conf-3.20.0-r1 x86_64 {alpine-conf} (MIT) [upgradable from: alpine-conf-3.20.0-r0]
117+
func (m *APKManager) parseUpgradablePackages(output string, installedPackages map[string]string) []models.Package {
118+
var packages []models.Package
119+
120+
// Regex to match the upgradable from pattern
121+
upgradableFromRegex := regexp.MustCompile(`\[upgradable from: (.+)\]`)
122+
123+
scanner := bufio.NewScanner(strings.NewReader(output))
124+
for scanner.Scan() {
125+
line := strings.TrimSpace(scanner.Text())
126+
if line == "" {
127+
continue
128+
}
129+
130+
// Check if line contains upgradable from pattern
131+
matches := upgradableFromRegex.FindStringSubmatch(line)
132+
if len(matches) < 2 {
133+
// Not an upgradable package line
134+
continue
135+
}
136+
137+
// Extract the old version package name
138+
oldPackageWithVersion := matches[1]
139+
140+
// Parse the main line to get new version
141+
// Format: package-name-new-version arch {origin} (license) [upgradable from: ...]
142+
fields := strings.Fields(line)
143+
if len(fields) < 2 {
144+
m.logger.WithField("line", line).Debug("Skipping malformed upgradable package line")
145+
continue
146+
}
147+
148+
// First field contains package-name-new-version
149+
newPackageWithVersion := fields[0]
150+
151+
// Extract package name and versions
152+
newPackageName, newVersion := m.extractPackageNameAndVersion(newPackageWithVersion)
153+
oldPackageName, oldVersion := m.extractPackageNameAndVersion(oldPackageWithVersion)
154+
155+
// Verify package names match
156+
if newPackageName == "" || newVersion == "" || oldPackageName == "" || oldVersion == "" {
157+
m.logger.WithField("line", line).Debug("Failed to extract package name or version from upgradable line")
158+
continue
159+
}
160+
161+
if newPackageName != oldPackageName {
162+
m.logger.WithFields(logrus.Fields{
163+
"newPackage": newPackageName,
164+
"oldPackage": oldPackageName,
165+
}).Debug("Package names don't match in upgradable line, using new package name")
166+
}
167+
168+
// Use the current version from installed packages if available, otherwise use old version
169+
currentVersion := oldVersion
170+
if installedVersion, found := installedPackages[newPackageName]; found {
171+
currentVersion = installedVersion
172+
}
173+
174+
// Alpine doesn't have built-in security update tracking
175+
// We'll mark all updates as potentially security updates (conservative approach)
176+
isSecurityUpdate := false
177+
178+
packages = append(packages, models.Package{
179+
Name: newPackageName,
180+
CurrentVersion: currentVersion,
181+
AvailableVersion: newVersion,
182+
NeedsUpdate: true,
183+
IsSecurityUpdate: isSecurityUpdate,
184+
})
185+
}
186+
187+
return packages
188+
}
189+
190+
// extractPackageNameAndVersion extracts package name and version from a package string
191+
// Format: package-name-version-release
192+
// Example: alpine-conf-3.20.0-r1 -> packageName: "alpine-conf", version: "3.20.0-r1"
193+
// Example: zzz-doc-0.2.0-r0 -> packageName: "zzz-doc", version: "0.2.0-r0"
194+
func (m *APKManager) extractPackageNameAndVersion(packageWithVersion string) (packageName, version string) {
195+
// Find the first dash followed by a digit (version starts)
196+
// This handles packages with dashes in their names
197+
for i := 0; i < len(packageWithVersion); i++ {
198+
if packageWithVersion[i] == '-' && i+1 < len(packageWithVersion) {
199+
nextChar := packageWithVersion[i+1]
200+
// Check if the next character is a digit (version starts)
201+
if nextChar >= '0' && nextChar <= '9' {
202+
// This is the start of version
203+
packageName = packageWithVersion[:i]
204+
version = packageWithVersion[i+1:]
205+
return
206+
}
207+
}
208+
}
209+
210+
// If no version pattern found, return the whole string as package name
211+
packageName = packageWithVersion
212+
return
213+
}
214+

internal/packages/packages.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,20 @@ type Manager struct {
1414
logger *logrus.Logger
1515
aptManager *APTManager
1616
dnfManager *DNFManager
17+
apkManager *APKManager
1718
}
1819

1920
// New creates a new package manager
2021
func New(logger *logrus.Logger) *Manager {
2122
aptManager := NewAPTManager(logger)
2223
dnfManager := NewDNFManager(logger)
24+
apkManager := NewAPKManager(logger)
2325

2426
return &Manager{
2527
logger: logger,
2628
aptManager: aptManager,
2729
dnfManager: dnfManager,
30+
apkManager: apkManager,
2831
}
2932
}
3033

@@ -39,14 +42,21 @@ func (m *Manager) GetPackages() ([]models.Package, error) {
3942
return m.aptManager.GetPackages(), nil
4043
case "dnf", "yum":
4144
return m.dnfManager.GetPackages(), nil
45+
case "apk":
46+
return m.apkManager.GetPackages(), nil
4247
default:
4348
return nil, fmt.Errorf("unsupported package manager: %s", packageManager)
4449
}
4550
}
4651

4752
// detectPackageManager detects which package manager is available on the system
4853
func (m *Manager) detectPackageManager() string {
49-
// Check for APT first
54+
// Check for APK first (Alpine Linux)
55+
if _, err := exec.LookPath("apk"); err == nil {
56+
return "apk"
57+
}
58+
59+
// Check for APT
5060
if _, err := exec.LookPath("apt"); err == nil {
5161
return "apt"
5262
}
@@ -67,7 +77,7 @@ func (m *Manager) detectPackageManager() string {
6777

6878
// CombinePackageData combines and deduplicates installed and upgradable package lists
6979
func CombinePackageData(installedPackages map[string]string, upgradablePackages []models.Package) []models.Package {
70-
var packages []models.Package
80+
packages := make([]models.Package, 0)
7181
upgradableMap := make(map[string]bool)
7282

7383
// First, add all upgradable packages

0 commit comments

Comments
 (0)