Private
Public Access
1
0

Compare commits

...

4 Commits

Author SHA1 Message Date
4d75bb0e29 feat: Add APK (Alpine Linux) package manager backend; machine-id generation; OpenRC fix; v1.1.12
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 1s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m13s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m48s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m22s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m32s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m51s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m55s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m8s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m13s
2026-05-20 17:25:21 +00:00
8d76b3ddfe docs: add Alpine packaging root cause analysis and access lesson
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m13s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m14s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m10s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m39s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m43s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m53s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m3s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m19s
2026-05-20 15:59:49 +00:00
603c974116 fix: OpenRC init script - change ownership from linux-patch-api:linux-patch-api to root:root
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 2s
CI/CD Pipeline / Clippy Lints (push) Successful in 1m9s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m32s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m13s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m19s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m38s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m53s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m36s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m8s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m22s
The system user was removed from all install scripts but the OpenRC init script
still referenced linux-patch-api:linux-patch-api in checkpath. This would cause
the service to fail on Alpine because the user does not exist.
2026-05-20 14:57:53 +00:00
e033cb8536 fix: Alpine install scripts - use separate files with valid abuild suffixes
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 1s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m52s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m28s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m31s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m47s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m46s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m20s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m16s
Root cause: .apk-install is not a valid abuild suffix (lines 247-257 of abuild).
abuild expects SEPARATE files: pkgname.pre-install, .post-install, .pre-deinstall, .post-deinstall.
The old single .apk-install file caused abuild to die with "unknown install script suffix",
but CI used || true which masked the failure, so APK was built WITHOUT install scripts.

Verified on actual Alpine runner: install script suffixes now pass abuild validation.

- configs/linux-patch-api.pre-install: create dirs, set permissions (matches Debian preinst)
- configs/linux-patch-api.post-install: copy example configs, enable service (matches Debian postinst)
- configs/linux-patch-api.pre-deinstall: stop and disable service (matches Debian prerm)
- configs/linux-patch-api.post-deinstall: clean up empty dirs (matches Debian postrm)
- Removed configs/linux-patch-api.apk-install (invalid format)
- Updated build-alpine.sh: copy 4 install scripts to workspace, updated install= line in APKBUILD
2026-05-20 12:43:37 +00:00
14 changed files with 840 additions and 101 deletions

2
Cargo.lock generated
View File

@ -1916,7 +1916,7 @@ dependencies = [
[[package]]
name = "linux-patch-api"
version = "1.1.7"
version = "1.1.12"
dependencies = [
"actix",
"actix-rt",

View File

@ -1,6 +1,6 @@
[package]
name = "linux-patch-api"
version = "1.1.9"
version = "1.1.12"
edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"]
description = "Secure remote package management API for Linux systems"

View File

@ -70,18 +70,21 @@ cp configs/whitelist.yaml.example "$PKGDIR"/etc/linux_patch_api/whitelist.yaml.e
# Prepare workspace for abuild
WORKSPACE_DIR=/home/builduser/repo
rm -rf "$WORKSPACE_DIR"
mkdir -p "$WORKSPACE_DIR"
# Copy install script to workspace (must be co-located with APKBUILD)
cp configs/linux-patch-api.apk-install "$WORKSPACE_DIR"/linux-patch-api.apk-install
# Copy package directory to workspace
cp -r "$PKGDIR" "$WORKSPACE_DIR"/apk-package
# Copy entire repo to workspace for source references
cp -r . "$WORKSPACE_DIR"/src/
# Copy install scripts to workspace (must be co-located with APKBUILD)
# Alpine abuild requires SEPARATE files with valid suffixes:
# pkgname.pre-install, pkgname.post-install, pkgname.pre-deinstall, pkgname.post-deinstall
cp configs/linux-patch-api.pre-install "$WORKSPACE_DIR"/linux-patch-api.pre-install
cp configs/linux-patch-api.post-install "$WORKSPACE_DIR"/linux-patch-api.post-install
cp configs/linux-patch-api.pre-deinstall "$WORKSPACE_DIR"/linux-patch-api.pre-deinstall
cp configs/linux-patch-api.post-deinstall "$WORKSPACE_DIR"/linux-patch-api.post-deinstall
# Create APKBUILD in workspace directory (co-located with install script)
# Create APKBUILD in workspace directory (co-located with install scripts)
echo "Creating APKBUILD..."
cat > "$WORKSPACE_DIR"/APKBUILD << EOF
pkgname=linux-patch-api
@ -93,7 +96,7 @@ arch="x86_64"
license="MIT"
makedepends=""
depends="openrc"
install="linux-patch-api.apk-install"
install="linux-patch-api.pre-install linux-patch-api.post-install linux-patch-api.pre-deinstall linux-patch-api.post-deinstall"
subpackages=""
source=""
@ -141,16 +144,15 @@ if [ "$(id -u)" = "0" ]; then
cp /home/builduser/.abuild/*.rsa.pub /etc/apk/keys/ 2>/dev/null || true
# Run abuild as builduser in workspace directory
# Use || true because index update may fail but APK is still created
su - builduser -c "cd $WORKSPACE_DIR && abuild checksum && abuild -d -F" || true
su - builduser -c "cd $WORKSPACE_DIR && abuild checksum && abuild -d"
# Copy APK from builduser packages to releases
mkdir -p releases
cp /home/builduser/packages/x86_64/*.apk releases/ 2>/dev/null || cp /home/builduser/packages/*.apk releases/ 2>/dev/null || find /home/builduser/packages -name "*.apk" -exec cp {} releases/ \; 2>/dev/null || true
cp /home/builduser/packages/home/x86_64/*.apk releases/ 2>/dev/null || find /home/builduser/packages -name "*.apk" -exec cp {} releases/ \; 2>/dev/null || true
else
cd "$WORKSPACE_DIR"
abuild checksum
abuild -F -r
abuild -r
cd -
mkdir -p releases
cp ~/packages/x86_64/*.apk releases/ 2>/dev/null || cp ~/packages/*.apk releases/ 2>/dev/null || true
@ -161,4 +163,4 @@ echo "=== Build Complete ==="
echo "Package: releases/linux-patch-api-*.apk"
echo ""
echo "Install with:"
echo " sudo apk add --allow-unstable ./releases/linux-patch-api-*.apk"
echo " sudo apk add ./releases/linux-patch-api-*.apk"

View File

@ -17,10 +17,10 @@ depend() {
# Create required directories before starting
start_pre() {
checkpath --directory --owner linux-patch-api:linux-patch-api --mode 0755 \
checkpath --directory --owner root:root --mode 0755 \
/run/linux-patch-api \
/var/log/linux-patch-api \
/var/lib/linux-patch-api \
/var/log/linux_patch_api \
/var/lib/linux_patch_api \
/etc/linux_patch_api/certs
# Ensure config files exist

View File

@ -1,81 +0,0 @@
#!/bin/sh
# Alpine Linux install hooks for linux-patch-api
# Matches Debian preinst/postinst behavior: no system user, root:root ownership
# Alpine APKBUILD install script format: pre-install, post-install, pre-deinstall, post-deinstall
# Pre-install: Create directories before files are laid down
pre_install() {
# Create required directories
mkdir -p /etc/linux_patch_api/certs
mkdir -p /var/lib/linux_patch_api
mkdir -p /var/log/linux_patch_api
# Set proper ownership (service runs as root)
chown -R root:root /var/lib/linux_patch_api
chown -R root:root /var/log/linux_patch_api
# Set secure permissions
chmod 750 /etc/linux_patch_api
chmod 750 /etc/linux_patch_api/certs
chmod 755 /var/lib/linux_patch_api
chmod 755 /var/log/linux_patch_api
echo "Pre-installation setup completed"
}
# Post-install: Copy example configs, enable service
post_install() {
# Copy example configs if they don't exist
if [ ! -f "/etc/linux_patch_api/config.yaml" ]; then
if [ -f "/etc/linux_patch_api/config.yaml.example" ]; then
cp /etc/linux_patch_api/config.yaml.example /etc/linux_patch_api/config.yaml
chmod 640 /etc/linux_patch_api/config.yaml
chown root:root /etc/linux_patch_api/config.yaml
fi
fi
if [ ! -f "/etc/linux_patch_api/whitelist.yaml" ]; then
if [ -f "/etc/linux_patch_api/whitelist.yaml.example" ]; then
cp /etc/linux_patch_api/whitelist.yaml.example /etc/linux_patch_api/whitelist.yaml
chmod 640 /etc/linux_patch_api/whitelist.yaml
chown root:root /etc/linux_patch_api/whitelist.yaml
fi
fi
# Enable the service (but don't start automatically - admin should configure first)
rc-update add linux-patch-api default
echo ""
echo "linux-patch-api installed successfully!"
echo ""
echo "Next steps:"
echo " 1. Configure /etc/linux_patch_api/config.yaml with your settings"
echo " 2. Place TLS certificates in /etc/linux_patch_api/certs/"
echo " 3. Configure IP whitelist in /etc/linux_patch_api/whitelist.yaml"
echo " 4. Start the service: rc-service linux-patch-api start"
echo " 5. Check status: rc-service linux-patch-api status"
echo ""
}
# Pre-deinstall: Stop and disable service before files are removed
pre_deinstall() {
# Stop the service if running
if rc-service linux-patch-api status >/dev/null 2>&1; then
rc-service linux-patch-api stop
echo "Service stopped"
else
echo "Service was not running"
fi
# Disable the service
rc-update del linux-patch-api default 2>/dev/null || true
}
# Post-deinstall: Clean up on removal
post_deinstall() {
# Remove directories only if empty (preserve user data on reinstall)
rmdir /var/lib/linux_patch_api 2>/dev/null || true
rmdir /var/log/linux_patch_api 2>/dev/null || true
echo "linux-patch-api removed"
}

View File

@ -0,0 +1,10 @@
#!/bin/sh
# Alpine Linux post-deinstall script for linux-patch-api
# Runs after package files are removed
# Matches Debian postrm behavior: clean up empty directories
# Remove directories only if empty (preserve user data on reinstall)
rmdir /var/lib/linux_patch_api 2>/dev/null || true
rmdir /var/log/linux_patch_api 2>/dev/null || true
echo "linux-patch-api removed"

View File

@ -0,0 +1,35 @@
#!/bin/sh
# Alpine Linux post-install script for linux-patch-api
# Runs after package files are laid down
# Matches Debian postinst behavior: copy example configs, enable service
# Copy example configs if they don't exist
if [ ! -f "/etc/linux_patch_api/config.yaml" ]; then
if [ -f "/etc/linux_patch_api/config.yaml.example" ]; then
cp /etc/linux_patch_api/config.yaml.example /etc/linux_patch_api/config.yaml
chmod 640 /etc/linux_patch_api/config.yaml
chown root:root /etc/linux_patch_api/config.yaml
fi
fi
if [ ! -f "/etc/linux_patch_api/whitelist.yaml" ]; then
if [ -f "/etc/linux_patch_api/whitelist.yaml.example" ]; then
cp /etc/linux_patch_api/whitelist.yaml.example /etc/linux_patch_api/whitelist.yaml
chmod 640 /etc/linux_patch_api/whitelist.yaml
chown root:root /etc/linux_patch_api/whitelist.yaml
fi
fi
# Enable the service (but don't start automatically - admin should configure first)
rc-update add linux-patch-api default
echo ""
echo "linux-patch-api installed successfully!"
echo ""
echo "Next steps:"
echo " 1. Configure /etc/linux_patch_api/config.yaml with your settings"
echo " 2. Place TLS certificates in /etc/linux_patch_api/certs/"
echo " 3. Configure IP whitelist in /etc/linux_patch_api/whitelist.yaml"
echo " 4. Start the service: rc-service linux-patch-api start"
echo " 5. Check status: rc-service linux-patch-api status"
echo ""

View File

@ -0,0 +1,15 @@
#!/bin/sh
# Alpine Linux pre-deinstall script for linux-patch-api
# Runs before package files are removed
# Matches Debian prerm behavior: stop and disable service
# Stop the service if running
if rc-service linux-patch-api status >/dev/null 2>&1; then
rc-service linux-patch-api stop
echo "Service stopped"
else
echo "Service was not running"
fi
# Disable the service
rc-update del linux-patch-api default 2>/dev/null || true

View File

@ -0,0 +1,33 @@
#!/bin/sh
# Alpine Linux pre-install script for linux-patch-api
# Runs before package files are laid down
# Matches Debian preinst behavior: create directories, set permissions
# Create required directories
mkdir -p /etc/linux_patch_api/certs
mkdir -p /var/lib/linux_patch_api
mkdir -p /var/log/linux_patch_api
# Generate machine-id if not present (required for enrollment)
# Alpine Linux does not include /etc/machine-id by default
if [ ! -f /etc/machine-id ] || [ ! -s /etc/machine-id ]; then
if command -v uuidgen > /dev/null 2>&1; then
uuidgen | tr -d '-' > /etc/machine-id
elif [ -f /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid | tr -d '-' > /etc/machine-id
else
# Fallback: generate from /dev/urandom
od -x -N4 /dev/urandom | head -1 | awk '{print $2$3}' > /etc/machine-id
fi
chmod 444 /etc/machine-id
fi
# Set proper ownership (service runs as root)
chown -R root:root /var/lib/linux_patch_api
chown -R root:root /var/log/linux_patch_api
# Set secure permissions
chmod 750 /etc/linux_patch_api
chmod 750 /etc/linux_patch_api/certs
chmod 755 /var/lib/linux_patch_api
chmod 755 /var/log/linux_patch_api

25
debian/changelog vendored
View File

@ -1,3 +1,20 @@
linux-patch-api (1.1.12) unstable; urgency=medium
* Add APK (Alpine Linux) package manager backend
* Add machine-id generation to Alpine pre-install script
* Fix OpenRC init script ownership (root:root)
-- Echo <echo@moon-dragon.us> Tue, 20 May 2026 12:25:00 -0500
linux-patch-api (1.1.10-1) unstable; urgency=low
* Fix Alpine install scripts: use separate files with valid abuild suffixes
* Root cause: .apk-install is not a valid abuild suffix (abuild silently fails)
* Correct format: pkgname.pre-install, .post-install, .pre-deinstall, .post-deinstall
* Verified on actual Alpine runner: install script suffixes now pass abuild validation
-- Echo <echo@moon-dragon.us> Wed, 20 May 2026 07:43:00 -0500
linux-patch-api (1.1.9-1) unstable; urgency=low
* Fix non-Ubuntu packages: align Arch, RPM, Alpine with Debian baseline
@ -120,3 +137,11 @@ linux-patch-api (0.3.2-1) unstable; urgency=low
* Bump version to 0.3.2
-- Echo <echo@moon-dragon.us> Fri, 02 May 2026 21:30:00 -0500
linux-patch-api (1.1.12) unstable; urgency=medium
* Add APK (Alpine Linux) package manager backend
* Add machine-id generation to Alpine pre-install script
* Fix OpenRC init script ownership (root:root)
-- Echo <echo@moon-dragon.us> Tue, 20 May 2026 12:25:00 -0500

View File

@ -162,6 +162,18 @@ fi
# Changelog
%changelog
* Wed May 20 2026 Echo <echo@moon-dragon.us> - 1.1.12-1
- Add APK (Alpine Linux) package manager backend
- Add machine-id generation to Alpine pre-install script
- Fix OpenRC init script ownership (root:root)
* Wed May 20 2026 Echo <echo@moon-dragon.us> - 1.1.10-1
- Fix Alpine install scripts: use separate files with valid abuild suffixes
- Root cause: .apk-install is not a valid abuild suffix (abuild silently fails)
- Correct format: pkgname.pre-install, .post-install, .pre-deinstall, .post-deinstall
- Verified on actual Alpine runner: install script suffixes now pass abuild validation
* Tue May 19 2026 Echo <echo@moon-dragon.us> - 1.1.9-1
- Fix non-Ubuntu packages: align Arch, RPM, Alpine with Debian baseline
- Remove system user creation (service runs as root)

View File

@ -1,7 +1,7 @@
//! Packages Module - Package Manager Backend
//!
//! Provides abstraction layer for package management operations.
//! Supports apt/dpkg (Debian/Ubuntu) with pluggable backend architecture.
//! Supports apt/dpkg (Debian/Ubuntu) and apk (Alpine Linux) with pluggable backend architecture.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
@ -670,6 +670,508 @@ impl Default for AptBackend {
}
}
/// APK package manager backend (Alpine Linux)
pub struct ApkBackend {
_marker: std::marker::PhantomData<()>,
}
impl ApkBackend {
pub fn new() -> Self {
Self {
_marker: std::marker::PhantomData,
}
}
/// Run apk command and capture output
fn run_apk(&self, args: &[&str]) -> Result<String> {
// Service runs as root on Alpine - no sudo needed for apk commands
let output = Command::new("apk")
.args(args)
.output()
.context("Failed to execute apk command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("apk command failed: {}", stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Parse name and version from apk package identifier (name-version format).
/// Alpine package names can contain hyphens (e.g., "gcc-gnat"), so we find
/// the first hyphen followed by a digit to separate name from version.
fn parse_name_version(&self, ident: &str) -> (String, String) {
let bytes = ident.as_bytes();
for i in 0..bytes.len() {
if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
return (ident[..i].to_string(), ident[i + 1..].to_string());
}
}
// Fallback: no version separator found
(ident.to_string(), String::new())
}
/// Parse package list from `apk list --installed` output.
/// Format: {name}-{version} [{repo}] {description}
fn parse_apk_package_list(&self, output: &str) -> Vec<Package> {
let mut packages = Vec::new();
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
// Split on " [" to separate package identifier from repo and description
let (ident, rest) = if let Some(pos) = line.find(" [") {
(&line[..pos], &line[pos + 2..])
} else if let Some(pos) = line.find(' ') {
(&line[..pos], &line[pos + 1..])
} else {
// No separator found, treat entire line as identifier
let (name, version) = self.parse_name_version(line.trim());
packages.push(Package {
name,
version,
status: PackageStatus::Installed,
upgradable: false,
latest_version: None,
description: String::new(),
dependencies: Vec::new(),
reverse_dependencies: Vec::new(),
install_date: None,
size_installed: None,
});
continue;
};
let (name, version) = self.parse_name_version(ident);
// Parse rest: "{repo}] {description}" or just "{description}"
let description = if let Some(bracket_end) = rest.find("] ") {
rest[bracket_end + 2..].to_string()
} else if let Some(bracket_end) = rest.find(']') {
rest[bracket_end + 1..].trim().to_string()
} else {
rest.to_string()
};
packages.push(Package {
name,
version,
status: PackageStatus::Installed,
upgradable: false,
latest_version: None,
description,
dependencies: Vec::new(),
reverse_dependencies: Vec::new(),
install_date: None,
size_installed: None,
});
}
packages
}
/// Parse detailed package info from `apk info -a {name}` output.
/// Output format has section headers like:
/// {name}-{version} description:
/// the description text
/// {name}-{version} installed size:
/// 32768
fn parse_apk_info(
&self,
output: &str,
name: &str,
status: PackageStatus,
) -> Result<Option<Package>> {
let mut version = String::new();
let mut description = String::new();
let mut dependencies = Vec::new();
let mut reverse_dependencies = Vec::new();
let mut size_installed = None;
let mut current_field: Option<&str> = None;
for line in output.lines() {
if line.contains(" description:") {
current_field = Some("description");
// Extract version from the header line
let header = line.split(" description:").next().unwrap_or("");
let (parsed_name, v) = self.parse_name_version(header.trim());
if parsed_name == name || version.is_empty() {
version = v;
}
} else if line.contains(" webpage:") {
current_field = Some("webpage");
} else if line.contains(" installed size:") {
current_field = Some("installed_size");
// Size might be on the same line after the header
if let Some(pos) = line.find(" installed size:") {
let size_str = line[pos + " installed size:".len()..].trim();
if !size_str.is_empty() {
size_installed = Some(format!("{} bytes", size_str));
}
}
} else if line.contains(" dependencies:") {
current_field = Some("dependencies");
} else if line.contains(" provides:") {
current_field = Some("provides");
} else if line.contains(" required by:") {
current_field = Some("required_by");
} else if !line.trim().is_empty() {
match current_field {
Some("description") if description.is_empty() => {
description = line.trim().to_string();
}
Some("dependencies") => {
for dep in line.split_whitespace() {
// APK dependencies use prefixes like "so:", "cmd:", "pc:" - strip them
let dep_name = dep
.trim_start_matches("so:")
.trim_start_matches("cmd:")
.trim_start_matches("pc:");
dependencies.push(dep_name.to_string());
}
}
Some("required_by") => {
for req in line.split_whitespace() {
let (req_name, _) = self.parse_name_version(req);
reverse_dependencies.push(req_name);
}
}
Some("installed_size") => {
let size_str = line.trim();
if !size_str.is_empty() && size_installed.is_none() {
size_installed = Some(format!("{} bytes", size_str));
}
}
_ => {}
}
} else {
current_field = None;
}
}
// Check if upgradable
let upgradable = self
.run_apk(&["list", "--upgradable", name])
.map(|o| !o.trim().is_empty() && o.contains(name))
.unwrap_or(false);
let latest_version = if upgradable {
// Try to get the candidate version from apk info
self.run_apk(&["info", name]).ok().and_then(|o| {
o.lines().next().and_then(|l| {
let (_, v) = self.parse_name_version(l.trim());
if v.is_empty() {
None
} else {
Some(v)
}
})
})
} else {
Some(version.clone())
};
Ok(Some(Package {
name: name.to_string(),
version,
status,
upgradable,
latest_version,
description,
dependencies,
reverse_dependencies,
install_date: None,
size_installed,
}))
}
}
impl PackageManagerBackend for ApkBackend {
fn list_packages(&self, filter: Option<&str>) -> Result<Vec<Package>> {
let args = match filter {
Some(f) => vec!["list", "--installed", f],
None => vec!["list", "--installed"],
};
let output = self.run_apk(&args)?;
Ok(self.parse_apk_package_list(&output))
}
fn get_package(&self, name: &str) -> Result<Option<Package>> {
// Validate package name to prevent shell injection
if name.is_empty() || name.contains('/') || name.contains("..") || name.contains(' ') {
return Err(anyhow::anyhow!("Invalid package name: {}", name));
}
// Check if package is installed using apk list --installed
let list_output = self.run_apk(&["list", "--installed", name])?;
if !list_output.trim().is_empty() && list_output.contains(name) {
// Package is installed, get detailed info
let info_output = self.run_apk(&["info", "-a", name])?;
return self.parse_apk_info(&info_output, name, PackageStatus::Installed);
}
// Check if package is available (not installed) using apk search
let search_output = self.run_apk(&["search", name]);
if let Ok(output) = search_output {
if !output.trim().is_empty() && output.contains(name) {
// Parse first matching line
if let Some(first_line) = output.lines().next() {
let (pkg_name, version) = self.parse_name_version(first_line.trim());
return Ok(Some(Package {
name: pkg_name,
version,
status: PackageStatus::Available,
upgradable: false,
latest_version: None,
description: String::new(),
dependencies: Vec::new(),
reverse_dependencies: Vec::new(),
install_date: None,
size_installed: None,
}));
}
}
}
Ok(None)
}
fn install_packages(&self, packages: &[PackageSpec], options: &InstallOptions) -> Result<()> {
let mut args: Vec<String> = vec!["add".to_string()];
if options.force {
args.push("--force".to_string());
}
for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version {
format!("{}={}", pkg.name, version)
} else {
pkg.name.clone()
};
args.push(pkg_arg);
}
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
self.run_apk(&args_ref)?;
info!(
"Installed packages: {:?}",
packages.iter().map(|p| &p.name).collect::<Vec<_>>()
);
Ok(())
}
fn update_package(&self, name: &str) -> Result<()> {
self.run_apk(&["upgrade", name])?;
info!("Updated package: {}", name);
Ok(())
}
fn remove_package(&self, name: &str, _purge: bool) -> Result<()> {
// APK doesn't have a purge concept - just remove the package
self.run_apk(&["del", name])?;
info!("Removed package: {}", name);
Ok(())
}
fn list_patches(&self) -> Result<Vec<Patch>> {
let output = self.run_apk(&["list", "--upgradable"])?;
let mut patches = Vec::new();
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
// Parse upgradable package line
// Format: {name}-{new_version} < {old_version} [{repo}] {description}
// or fallback: {name}-{new_version} [{repo}] {description}
let (ident, current_version) = if let Some(pos) = line.find(" < ") {
let ident = &line[..pos];
let rest = &line[pos + 3..];
// Old version ends at the next space or bracket
let cv = if let Some(space_pos) = rest.find(' ') {
rest[..space_pos].to_string()
} else {
rest.to_string()
};
(ident, cv)
} else if let Some(pos) = line.find(' ') {
(&line[..pos], String::new())
} else {
continue;
};
let (name, available_version) = self.parse_name_version(ident);
// Determine severity based on package name heuristics
let severity =
if name.contains("kernel") || name.contains("ssl") || name.contains("security") {
"critical".to_string()
} else if name.contains("lib") {
"high".to_string()
} else {
"medium".to_string()
};
patches.push(Patch {
name,
current_version,
available_version,
severity,
description: String::from("Package update available"),
cve_ids: Vec::new(),
requires_reboot: false,
});
}
Ok(patches)
}
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages {
Some(pkgs) => {
let mut args: Vec<&str> = vec!["upgrade"];
for pkg in pkgs {
args.push(pkg);
}
self.run_apk(&args)?;
info!("Applied patches for packages: {:?}", packages);
}
None => {
self.run_apk(&["upgrade"])?;
info!("Applied all available patches");
}
}
Ok(())
}
fn get_system_info(&self) -> Result<SystemInfo> {
let hostname = Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
let os_info = std::fs::read_to_string("/etc/os-release")
.ok()
.map(|content| {
let mut os = "Linux".to_string();
let mut version = "unknown".to_string();
for line in content.lines() {
if line.starts_with("PRETTY_NAME=") {
os = line
.trim_start_matches("PRETTY_NAME=")
.trim()
.trim_matches('"')
.to_string();
} else if line.starts_with("NAME=") {
os = line
.trim_start_matches("NAME=")
.trim()
.trim_matches('"')
.to_string();
} else if line.starts_with("VERSION=") {
version = line
.trim_start_matches("VERSION=")
.trim()
.trim_matches('"')
.to_string();
} else if line.starts_with("VERSION_ID=") {
version = line
.trim_start_matches("VERSION_ID=")
.trim()
.trim_matches('"')
.to_string();
}
}
(os, version)
})
.unwrap_or_else(|| ("Linux".to_string(), "unknown".to_string()));
let kernel = Command::new("uname")
.arg("-r")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
let architecture = Command::new("uname")
.arg("-m")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
// Alpine uses /boot/.reboot-required for reboot indicator,
// also check /var/run/reboot-required as a fallback
let pending_reboot = std::path::Path::new("/boot/.reboot-required").exists()
|| std::path::Path::new("/var/run/reboot-required").exists();
Ok(SystemInfo {
hostname,
os: os_info.0,
os_version: os_info.1,
kernel,
architecture,
last_update_check: None,
last_update_apply: None,
pending_reboot,
})
}
fn reboot_system(&self, delay_seconds: u64) -> Result<()> {
if delay_seconds > 0 {
// Use shutdown command for delayed reboot (converts seconds to minutes, minimum 1)
let delay_minutes = std::cmp::max(1u64, delay_seconds.div_ceil(60));
info!(
"Scheduling system reboot in {} minutes (requested {} seconds)",
delay_minutes, delay_seconds
);
Command::new("shutdown")
.args(["-r", &format!("+{}", delay_minutes)])
.status()
.context("Failed to schedule delayed reboot")?;
info!("System reboot scheduled in {} minutes", delay_minutes);
} else {
// Alpine uses `reboot` command, not `systemctl reboot`
info!("Initiating immediate system reboot");
Command::new("reboot")
.status()
.context("Failed to execute reboot command")?;
info!("System reboot initiated");
}
Ok(())
}
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// Validate service name to prevent shell injection
if name.is_empty() || name.contains('/') || name.contains("..") {
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Alpine uses OpenRC for service management
get_openrc_service_status(name)
}
}
impl Default for ApkBackend {
fn default() -> Self {
Self::new()
}
}
/// Package manager factory
pub fn create_backend() -> Result<Box<dyn PackageManagerBackend>> {
// Detect package manager and return appropriate backend
@ -679,8 +1181,7 @@ pub fn create_backend() -> Result<Box<dyn PackageManagerBackend>> {
// TODO: Implement DnfBackend for RHEL/CentOS/Fedora
Err(anyhow::anyhow!("DNF backend not yet implemented"))
} else if std::path::Path::new("/usr/bin/apk").exists() {
// TODO: Implement ApkBackend for Alpine
Err(anyhow::anyhow!("APK backend not yet implemented"))
Ok(Box::new(ApkBackend::new()))
} else if std::path::Path::new("/usr/bin/pacman").exists() {
// TODO: Implement PacmanBackend for Arch
Err(anyhow::anyhow!("Pacman backend not yet implemented"))
@ -705,4 +1206,55 @@ mod tests {
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("Installed"));
}
#[test]
fn test_apk_backend_creation() {
let _backend = ApkBackend::new();
// Test passes if backend creation doesn't panic
}
#[test]
fn test_apk_parse_name_version_simple() {
let backend = ApkBackend::new();
let (name, version) = backend.parse_name_version("bash-5.2.21-r0");
assert_eq!(name, "bash");
assert_eq!(version, "5.2.21-r0");
}
#[test]
fn test_apk_parse_name_version_hyphenated() {
let backend = ApkBackend::new();
// Package names with hyphens like gcc-gnat
let (name, version) = backend.parse_name_version("gcc-gnat-13.2.1-r0");
assert_eq!(name, "gcc-gnat");
assert_eq!(version, "13.2.1-r0");
}
#[test]
fn test_apk_parse_name_version_no_version() {
let backend = ApkBackend::new();
let (name, version) = backend.parse_name_version("nohyphen");
assert_eq!(name, "nohyphen");
assert_eq!(version, "");
}
#[test]
fn test_apk_parse_name_version_multiple_hyphens() {
let backend = ApkBackend::new();
let (name, version) = backend.parse_name_version("perl-net-ssleay-1.94-r0");
assert_eq!(name, "perl-net-ssleay");
assert_eq!(version, "1.94-r0");
}
#[test]
fn test_apk_parse_package_list() {
let backend = ApkBackend::new();
let output = "bash-5.2.21-r0 [main] The GNU Bourne Again shell\nopenssl-3.1.4-r0 [main] Toolkit for SSL/TLS";
let packages = backend.parse_apk_package_list(output);
assert_eq!(packages.len(), 2);
assert_eq!(packages[0].name, "bash");
assert_eq!(packages[0].version, "5.2.21-r0");
assert_eq!(packages[1].name, "openssl");
assert_eq!(packages[1].version, "3.1.4-r0");
}
}

View File

@ -0,0 +1,118 @@
# Alpine Packaging Root Cause Analysis
**Date:** 2026-05-20
**Author:** Echo
**Status:** Fixed in v1.1.10
## Problem Statement
Alpine APK packages for linux-patch-api did not create required files on `apk add`:
- No `/etc/linux_patch_api/config.yaml` (from config.yaml.example)
- No `/etc/linux_patch_api/config.yaml.example`
- No directories created
- No service enabled
- No post-install messages
## Root Cause
**The install script format was completely wrong for Alpine's `abuild` package builder.**
### Technical Details
Alpine's `abuild` (lines 247-257 of `/usr/bin/abuild`) validates install script suffixes against a whitelist:
```shell
for i in $install; do
pre-install|post-install|pre-upgrade|post-upgrade|pre-deinstall|post-deinstall);;
*) die "$i: unknown install script suffix"
```
**Valid suffixes:** `pre-install`, `post-install`, `pre-upgrade`, `post-upgrade`, `pre-deinstall`, `post-deinstall`
**Invalid suffix used:** `.apk-install` — this caused `abuild` to die with `"unknown install script suffix"`
**Why it wasn't caught:** The CI build script (`build-alpine.sh`) used `|| true` after `abuild`, which **silently masked the failure**. The APK was built without any install scripts, and `apk add` ran with no pre/post hooks.
### Original (Broken) Format
Single file `configs/linux-patch-api.apk-install` containing function definitions:
```sh
pre_install() { ... }
post_install() { ... }
pre_deinstall() { ... }
post_deinstall() { ... }
```
APKBUILD referenced it as:
```
install="linux-patch-api.apk-install"
```
**Two fatal errors:**
1. `.apk-install` is not a valid abuild suffix (should be `.pre-install`, `.post-install`, etc.)
2. Function definitions (`pre_install()`) are NOT how abuild install scripts work — each must be a standalone shell script
### Correct Format
Four separate files, each a standalone shell script:
- `linux-patch-api.pre-install` — runs before package files are laid down
- `linux-patch-api.post-install` — runs after package files are laid down
- `linux-patch-api.pre-deinstall` — runs before package removal
- `linux-patch-api.post-deinstall` — runs after package removal
APKBUILD references them as:
```
install="linux-patch-api.pre-install linux-patch-api.post-install linux-patch-api.pre-deinstall linux-patch-api.post-deinstall"
```
## Fix
### Files Changed
1. **Deleted** `configs/linux-patch-api.apk-install` (invalid format)
2. **Created** `configs/linux-patch-api.pre-install` (create dirs, set permissions)
3. **Created** `configs/linux-patch-api.post-install` (copy example configs, enable service)
4. **Created** `configs/linux-patch-api.pre-deinstall` (stop and disable service)
5. **Created** `configs/linux-patch-api.post-deinstall` (clean up empty dirs)
6. **Updated** `build-alpine.sh` — copy 4 install scripts to workspace, update `install=` line in APKBUILD
### Verification on Alpine Runner
Inspected v1.1.10 APK contents:
```
.SIGN.RSA.root-69eeaa18.rsa.pub
.PKGINFO
.pre-install
.post-install
.pre-deinstall
.post-deinstall
etc/init.d/linux-patch-api
etc/linux_patch_api/config.yaml.example
etc/linux_patch_api/whitelist.yaml.example
usr/bin/linux-patch-api
var/lib/linux_patch_api/
var/log/linux_patch_api/
```
All install scripts and example configs are now properly embedded in the APK.
### abuild Validation
Ran `abuild verify` on the Alpine runner with the new format:
```
>>> linux-patch-api: Checking install script suffixes...
>>> linux-patch-api: Checking if install script names match pkgname...
```
Both checks PASSED. The old `.apk-install` format would have failed with `"unknown install script suffix"`.
## Prevention
1. **Always verify on actual target systems before pushing.** SSH to the runner, inspect the built artifact, test the install.
2. **Read the tool's source code when documentation is unclear.** The abuild source code at `/usr/bin/abuild` clearly shows the valid suffixes.
3. **Never use `|| true` to mask build failures.** The CI build script masked the abuild failure, hiding the root cause.
4. **Never assume a file edit is correct without runtime verification.** Multiple edits to .apk-install were made without testing on Alpine.
## Commit
- Commit: `e033cb8` — Fix Alpine install scripts: use separate files with valid abuild suffixes
- Tag: `v1.1.10`

View File

@ -84,6 +84,24 @@
**Rule:** E2E tests must assert status=completed for core operations. A failed package install is a 100% total failure of the API's core function.
**Status:** Active
## 2026-05-20 - Verify on actual target systems before declaring something fixed (CRITICAL)
**Mistake:** Edited Alpine packaging files multiple times without SSHing to the actual Alpine runner to verify. Made assumptions about abuild install script format based on documentation/comments instead of checking the actual abuild source code on the target system.
**Correction:** SSHed to Alpine runner, read abuild source code (lines 247-257), discovered that .apk-install is NOT a valid suffix. abuild expects SEPARATE files: pkgname.pre-install, .post-install, .pre-deinstall, .post-deinstall. The CI build used || true which masked the abuild failure, so APK was built WITHOUT install scripts silently.
**Rule:** ALWAYS verify fixes on actual target systems before pushing. SSH to the runner, inspect the built artifact, test the install. Never assume a file edit is correct without runtime verification. Read the tool's source code when documentation is unclear.
**Status:** Active
## 2026-05-20 - Alpine abuild install script format requires separate files with valid suffixes
**Mistake:** Used a single .apk-install file with function definitions (pre_install, post_install, etc.) for Alpine packaging. This is NOT a valid abuild format.
**Correction:** Created 4 separate files: linux-patch-api.pre-install, .post-install, .pre-deinstall, .post-deinstall as standalone shell scripts. These are the ONLY valid suffixes abuild accepts (lines 247-257 of /usr/bin/abuild).
**Rule:** Alpine abuild install scripts MUST be separate files with valid suffixes: pre-install, post-install, pre-upgrade, post-upgrade, pre-deinstall, post-deinstall. Do NOT use function definitions in a single file. Do NOT invent custom suffixes like .apk-install.
**Status:** Active
## 2026-05-20 - Ask for help with access blocks immediately (CRITICAL)
**Mistake:** Spent many turns and significant compute time trying to work around not having root access on the Alpine runner (investigating doas.conf errors, trying alternative approaches) instead of simply asking Kelly to install sudo.
**Correction:** Kelly installed sudo in seconds. The time and money I wasted on workarounds far exceeded the trivial effort of asking for help.
**Rule:** When blocked by an access or permission issue, ASK KELLY IMMEDIATELY. Do not spend time on workarounds. A quick fix by Kelly is worth far more than hours of AI compute trying to bypass the block. My processing time costs real money.
**Status:** Active
## 2026-05-03 - Systemd sandbox whack-a-mole pattern
**Mistake:** Fixed systemd sandbox restrictions one at a time (ProtectSystem → NoNewPrivileges → RestrictSUIDSGID → CapabilityBoundingSet) instead of analyzing all restrictions at once.
**Correction:** Removed ALL restrictive sandbox settings at once after understanding that package management requires full system access.