Private
Public Access
1
0

Compare commits

...

7 Commits

Author SHA1 Message Date
fb0ce8ac32 fix: RPM packaging - pre-build binary, fix ownership, fix deps, prevent stale cache
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 2s
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 1m55s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m29s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m23s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m33s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m45s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m33s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m6s
2026-05-20 19:45:38 +00:00
b932f6be38 docs: update changelog for v1.1.13
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 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m48s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m25s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m33s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m50s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m54s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m7s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m13s
2026-05-20 18:54:33 +00:00
5fa7fd0f90 fix: detect apk at /sbin/apk on Alpine (not just /usr/bin/apk); v1.1.13 2026-05-20 18:54:10 +00:00
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
16 changed files with 977 additions and 119 deletions

View File

@ -249,19 +249,46 @@ jobs:
- name: Install build dependencies - name: Install build dependencies
run: | run: |
sudo dnf install -y gcc rpm-build systemd-devel pkg-config openssl-devel sudo dnf install -y gcc rpm-build systemd-devel pkg-config openssl-devel
- name: Clean stale RPM artifacts
run: |
rm -f ~/rpmbuild/RPMS/x86_64/linux-patch-api-*.rpm
rm -f releases/linux-patch-api-*.rpm
- name: Build release binary - name: Build release binary
run: cargo build --release run: cargo build --release
- name: Build RPM package - name: Build RPM package
run: | run: |
chmod +x build-rpm.sh chmod +x build-rpm.sh
./build-rpm.sh SKIP_CARGO_BUILD=1 ./build-rpm.sh
- name: Verify RPM package
run: |
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
RPM_FILE=$(ls ~/rpmbuild/RPMS/x86_64/linux-patch-api-${VERSION}-*.rpm 2>/dev/null | head -1)
if [ -z "$RPM_FILE" ]; then
echo "ERROR: RPM package not found for version $VERSION!"
ls -la ~/rpmbuild/RPMS/x86_64/ 2>/dev/null || echo "RPM directory empty or missing"
exit 1
fi
RPM_VERSION=$(rpm -qp --queryformat '%{VERSION}' "$RPM_FILE" 2>/dev/null || true)
echo "RPM file: $RPM_FILE"
echo "RPM version: $RPM_VERSION"
echo "Expected version: $VERSION"
if [ "$RPM_VERSION" != "$VERSION" ]; then
echo "ERROR: RPM version ($RPM_VERSION) does not match expected version ($VERSION)!"
exit 1
fi
echo "RPM verification passed"
- name: Upload to Gitea Release - name: Upload to Gitea Release
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
env: env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: | run: |
TAG_NAME=${GITHUB_REF#refs/tags/} TAG_NAME=${GITHUB_REF#refs/tags/}
FILE=$(ls ~/rpmbuild/RPMS/x86_64/*.rpm 2>/dev/null | head -1) VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
FILE=$(ls ~/rpmbuild/RPMS/x86_64/linux-patch-api-${VERSION}-*.rpm 2>/dev/null | head -1)
if [ -z "$FILE" ]; then
echo "ERROR: No RPM found with version $VERSION for upload!"
exit 1
fi
chmod +x scripts/upload-release.sh chmod +x scripts/upload-release.sh
./scripts/upload-release.sh "$TAG_NAME" "$FILE" ./scripts/upload-release.sh "$TAG_NAME" "$FILE"

2
Cargo.lock generated
View File

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

View File

@ -1,6 +1,6 @@
[package] [package]
name = "linux-patch-api" name = "linux-patch-api"
version = "1.1.9" version = "1.1.14"
edition = "2021" edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"] authors = ["Echo <echo@moon-dragon.us>"]
description = "Secure remote package management API for Linux systems" 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 # Prepare workspace for abuild
WORKSPACE_DIR=/home/builduser/repo WORKSPACE_DIR=/home/builduser/repo
rm -rf "$WORKSPACE_DIR"
mkdir -p "$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 # Copy package directory to workspace
cp -r "$PKGDIR" "$WORKSPACE_DIR"/apk-package cp -r "$PKGDIR" "$WORKSPACE_DIR"/apk-package
# Copy entire repo to workspace for source references # Copy install scripts to workspace (must be co-located with APKBUILD)
cp -r . "$WORKSPACE_DIR"/src/ # 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..." echo "Creating APKBUILD..."
cat > "$WORKSPACE_DIR"/APKBUILD << EOF cat > "$WORKSPACE_DIR"/APKBUILD << EOF
pkgname=linux-patch-api pkgname=linux-patch-api
@ -93,7 +96,7 @@ arch="x86_64"
license="MIT" license="MIT"
makedepends="" makedepends=""
depends="openrc" 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="" subpackages=""
source="" source=""
@ -141,16 +144,15 @@ if [ "$(id -u)" = "0" ]; then
cp /home/builduser/.abuild/*.rsa.pub /etc/apk/keys/ 2>/dev/null || true cp /home/builduser/.abuild/*.rsa.pub /etc/apk/keys/ 2>/dev/null || true
# Run abuild as builduser in workspace directory # 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"
su - builduser -c "cd $WORKSPACE_DIR && abuild checksum && abuild -d -F" || true
# Copy APK from builduser packages to releases # Copy APK from builduser packages to releases
mkdir -p 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 else
cd "$WORKSPACE_DIR" cd "$WORKSPACE_DIR"
abuild checksum abuild checksum
abuild -F -r abuild -r
cd - cd -
mkdir -p releases mkdir -p releases
cp ~/packages/x86_64/*.apk releases/ 2>/dev/null || cp ~/packages/*.apk releases/ 2>/dev/null || true 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 "Package: releases/linux-patch-api-*.apk"
echo "" echo ""
echo "Install with:" 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

@ -2,19 +2,29 @@
# Build RPM Package for RHEL/CentOS/Fedora # Build RPM Package for RHEL/CentOS/Fedora
# Run on: RHEL 8/9, CentOS 8/9, Fedora 38+ # Run on: RHEL 8/9, CentOS 8/9, Fedora 38+
# Designed for native Gitea Actions runner execution # Designed for native Gitea Actions runner execution
#
# Build pattern: Pre-build binary BEFORE creating tarball (like Alpine/Arch)
# The binary is included in the source tarball so rpmbuild's %build
# section is a no-op. This avoids PATH issues where rpmbuild can't find
# cargo installed via rustup.
set -e set -e
echo "=== Linux Patch API - RPM Build Script ===" echo "=== Linux Patch API - RPM Build Script ==="
echo "" echo ""
# Source cargo environment (for rustup-installed toolchain in CI)
if [ -f "$HOME/.cargo/env" ]; then
. "$HOME/.cargo/env"
fi
# Check if running on RPM-based system # Check if running on RPM-based system
if ! command -v rpmbuild &> /dev/null; then if ! command -v rpmbuild &> /dev/null; then
echo "Installing RPM build tools..." echo "Installing RPM build tools..."
if command -v dnf &> /dev/null; then if command -v dnf &> /dev/null; then
dnf install -y rpm-build cargo rust gcc systemd-devel dnf install -y rpm-build
elif command -v yum &> /dev/null; then elif command -v yum &> /dev/null; then
yum install -y rpm-build cargo rust gcc systemd-devel yum install -y rpm-build
else else
echo "Error: Cannot install rpm-build. Please install manually." echo "Error: Cannot install rpm-build. Please install manually."
exit 1 exit 1
@ -23,13 +33,38 @@ fi
# Get version from Cargo.toml # Get version from Cargo.toml
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/') VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\([^"]*\)".*/\1/')
if [ -z "$VERSION" ]; then
echo "Error: Could not determine version from Cargo.toml"
exit 1
fi
echo "Building version: $VERSION" echo "Building version: $VERSION"
# Remove stale RPM artifacts to prevent uploading cached/old packages
echo "Cleaning stale RPM artifacts..."
rm -f ~/rpmbuild/RPMS/x86_64/linux-patch-api-*.rpm
rm -f releases/linux-patch-api-*.rpm
# Build release binary (skip if already built by CI)
if [ -z "$SKIP_CARGO_BUILD" ]; then
echo "Building release binary..."
cargo build --release
else
echo "Skipping cargo build (SKIP_CARGO_BUILD is set)"
fi
# Verify binary exists
if [ ! -f "target/release/linux-patch-api" ]; then
echo "Error: Pre-built binary not found at target/release/linux-patch-api"
echo "Run 'cargo build --release' first or unset SKIP_CARGO_BUILD"
exit 1
fi
# Setup RPM build directory structure # Setup RPM build directory structure
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
# Create source tarball (required by %autosetup in spec file) # Create source tarball with pre-built binary included
echo "Creating source tarball..." # (required by %autosetup in spec file)
echo "Creating source tarball with pre-built binary..."
TMPDIR=$(mktemp -d) TMPDIR=$(mktemp -d)
mkdir -p "$TMPDIR/linux-patch-api-${VERSION}" mkdir -p "$TMPDIR/linux-patch-api-${VERSION}"
@ -47,6 +82,12 @@ rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.abuild"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/apk-package" rm -rf "$TMPDIR/linux-patch-api-${VERSION}/apk-package"
rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.a0proj" rm -rf "$TMPDIR/linux-patch-api-${VERSION}/.a0proj"
# Re-create target/release with just the pre-built binary
# This is the key change: binary is in the tarball so %build is a no-op
mkdir -p "$TMPDIR/linux-patch-api-${VERSION}/target/release"
cp target/release/linux-patch-api "$TMPDIR/linux-patch-api-${VERSION}/target/release/"
chmod 755 "$TMPDIR/linux-patch-api-${VERSION}/target/release/linux-patch-api"
tar -czf ~/rpmbuild/SOURCES/linux-patch-api-${VERSION}.tar.gz -C "$TMPDIR" "linux-patch-api-${VERSION}" tar -czf ~/rpmbuild/SOURCES/linux-patch-api-${VERSION}.tar.gz -C "$TMPDIR" "linux-patch-api-${VERSION}"
rm -rf "$TMPDIR" rm -rf "$TMPDIR"
@ -54,10 +95,35 @@ rm -rf "$TMPDIR"
echo "Preparing spec file..." echo "Preparing spec file..."
sed "s/VERSION_PLACEHOLDER/$VERSION/" linux-patch-api.spec > ~/rpmbuild/SPECS/linux-patch-api.spec sed "s/VERSION_PLACEHOLDER/$VERSION/" linux-patch-api.spec > ~/rpmbuild/SPECS/linux-patch-api.spec
# Verify VERSION replacement succeeded
if grep -q 'VERSION_PLACEHOLDER' ~/rpmbuild/SPECS/linux-patch-api.spec; then
echo "Error: VERSION_PLACEHOLDER not replaced in spec file!"
exit 1
fi
echo "Spec file version verified: $VERSION"
# Build RPM # Build RPM
echo "Building RPM package..." echo "Building RPM package..."
rpmbuild -ba ~/rpmbuild/SPECS/linux-patch-api.spec rpmbuild -ba ~/rpmbuild/SPECS/linux-patch-api.spec
# Verify RPM was actually built
RPM_FILE=$(ls ~/rpmbuild/RPMS/x86_64/linux-patch-api-${VERSION}-*.rpm 2>/dev/null | head -1)
if [ -z "$RPM_FILE" ]; then
echo "Error: RPM package not found after build!"
echo "Looking for: ~/rpmbuild/RPMS/x86_64/linux-patch-api-${VERSION}-*.rpm"
ls -la ~/rpmbuild/RPMS/x86_64/ 2>/dev/null || echo "Directory empty or missing"
exit 1
fi
# Verify RPM contains the correct version
RPM_VERSION=$(rpm -qp --queryformat '%{VERSION}' "$RPM_FILE" 2>/dev/null || true)
echo "RPM built: $RPM_FILE"
echo "RPM version: $RPM_VERSION"
if [ "$RPM_VERSION" != "$VERSION" ]; then
echo "Error: RPM version ($RPM_VERSION) does not match expected version ($VERSION)!"
exit 1
fi
# Copy to releases directory # Copy to releases directory
echo "" echo ""
echo "Copying package to releases/..." echo "Copying package to releases/..."

View File

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

43
debian/changelog vendored
View File

@ -1,3 +1,38 @@
linux-patch-api (1.1.14) unstable; urgency=medium
* Fix RPM packaging: pre-build binary before tarball (like Alpine/Arch pattern)
* Fix rpmbuild can't find cargo in PATH - binary now included in source tarball
* Fix config file ownership: add %defattr(-,root,root,-) in %files section
* Fix Requires: libsystemd -> systemd-libs for Fedora compatibility
* Remove Requires: systemd (not needed, may not exist in containers)
* Add stale RPM cleanup and version verification to build-rpm.sh
* Support SKIP_CARGO_BUILD=1 like Alpine/Arch builds
-- Echo <echo@moon-dragon.us> Tue, 20 May 2026 14:44:00 -0500
linux-patch-api (1.1.13) unstable; urgency=medium
* Fix APK backend detection for Alpine (/sbin/apk not /usr/bin/apk)
-- Echo <echo@moon-dragon.us> Tue, 20 May 2026 13:55: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
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 linux-patch-api (1.1.9-1) unstable; urgency=low
* Fix non-Ubuntu packages: align Arch, RPM, Alpine with Debian baseline * Fix non-Ubuntu packages: align Arch, RPM, Alpine with Debian baseline
@ -120,3 +155,11 @@ linux-patch-api (0.3.2-1) unstable; urgency=low
* Bump version to 0.3.2 * Bump version to 0.3.2
-- Echo <echo@moon-dragon.us> Fri, 02 May 2026 21:30:00 -0500 -- 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

@ -21,8 +21,7 @@ BuildArch: x86_64
# BuildRequires: pkgconfig(systemd) # BuildRequires: pkgconfig(systemd)
# Runtime requirements # Runtime requirements
Requires: systemd Requires: systemd-libs
Requires: libsystemd
Requires: openssl-libs Requires: openssl-libs
Requires: ca-certificates Requires: ca-certificates
@ -45,10 +44,11 @@ Features:
%prep %prep
%autosetup -n linux-patch-api-%{version} %autosetup -n linux-patch-api-%{version}
# Build # Build - no-op, binary is pre-built and included in source tarball
# The binary is built by build-rpm.sh BEFORE creating the tarball,
# so cargo does not need to be in rpmbuild's PATH.
%build %build
export RUSTFLAGS="-C target-cpu=native" # Binary already built - nothing to do
cargo build --release --target x86_64-unknown-linux-gnu
# Install # Install
%install %install
@ -59,8 +59,8 @@ mkdir -p %{buildroot}/lib/systemd/system
mkdir -p %{buildroot}/var/log/linux_patch_api mkdir -p %{buildroot}/var/log/linux_patch_api
mkdir -p %{buildroot}/var/lib/linux_patch_api mkdir -p %{buildroot}/var/lib/linux_patch_api
# Install binary # Install binary (pre-built, included in tarball at target/release/)
cp target/x86_64-unknown-linux-gnu/release/linux-patch-api %{buildroot}/usr/bin/ cp target/release/linux-patch-api %{buildroot}/usr/bin/
chmod 755 %{buildroot}/usr/bin/linux-patch-api chmod 755 %{buildroot}/usr/bin/linux-patch-api
# Install systemd service # Install systemd service
@ -149,6 +149,7 @@ fi
# Files # Files
%files %files
%defattr(-,root,root,-)
/usr/bin/linux-patch-api /usr/bin/linux-patch-api
/lib/systemd/system/linux-patch-api.service /lib/systemd/system/linux-patch-api.service
%config(noreplace) /etc/linux_patch_api/config.yaml.example %config(noreplace) /etc/linux_patch_api/config.yaml.example
@ -162,6 +163,27 @@ fi
# Changelog # Changelog
%changelog %changelog
* Tue May 20 2026 Echo <echo@moon-dragon.us> - 1.1.14-1
- Fix RPM packaging: pre-build binary before tarball (like Alpine/Arch pattern)
- Fix rpmbuild can't find cargo in PATH - binary now included in source tarball
- Fix config file ownership: add %defattr(-,root,root,-) in %files section
- Fix Requires: libsystemd -> systemd-libs for Fedora compatibility
- Remove Requires: systemd (not needed, may not exist in containers)
- Add stale RPM cleanup and version verification to build-rpm.sh
- Support SKIP_CARGO_BUILD=1 like Alpine/Arch builds
* 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 * Tue May 19 2026 Echo <echo@moon-dragon.us> - 1.1.9-1
- Fix non-Ubuntu packages: align Arch, RPM, Alpine with Debian baseline - Fix non-Ubuntu packages: align Arch, RPM, Alpine with Debian baseline
- Remove system user creation (service runs as root) - Remove system user creation (service runs as root)
@ -186,7 +208,3 @@ fi
- Initial production release - Initial production release
- Secure mTLS-authenticated REST API for remote package management - Secure mTLS-authenticated REST API for remote package management
- 15 API endpoints for package install/remove, patch application, system management - 15 API endpoints for package install/remove, patch application, system management
- Asynchronous job processing with WebSocket status streaming
- IP whitelist enforcement and comprehensive audit logging
- Systemd integration with security hardening
- Supports RHEL 8/9, CentOS 8/9, Fedora 38+

View File

@ -1,7 +1,7 @@
//! Packages Module - Package Manager Backend //! Packages Module - Package Manager Backend
//! //!
//! Provides abstraction layer for package management operations. //! 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 anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; 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 /// Package manager factory
pub fn create_backend() -> Result<Box<dyn PackageManagerBackend>> { pub fn create_backend() -> Result<Box<dyn PackageManagerBackend>> {
// Detect package manager and return appropriate backend // Detect package manager and return appropriate backend
@ -678,9 +1180,10 @@ pub fn create_backend() -> Result<Box<dyn PackageManagerBackend>> {
} else if std::path::Path::new("/usr/bin/dnf").exists() { } else if std::path::Path::new("/usr/bin/dnf").exists() {
// TODO: Implement DnfBackend for RHEL/CentOS/Fedora // TODO: Implement DnfBackend for RHEL/CentOS/Fedora
Err(anyhow::anyhow!("DNF backend not yet implemented")) Err(anyhow::anyhow!("DNF backend not yet implemented"))
} else if std::path::Path::new("/usr/bin/apk").exists() { } else if std::path::Path::new("/usr/bin/apk").exists()
// TODO: Implement ApkBackend for Alpine || std::path::Path::new("/sbin/apk").exists()
Err(anyhow::anyhow!("APK backend not yet implemented")) {
Ok(Box::new(ApkBackend::new()))
} else if std::path::Path::new("/usr/bin/pacman").exists() { } else if std::path::Path::new("/usr/bin/pacman").exists() {
// TODO: Implement PacmanBackend for Arch // TODO: Implement PacmanBackend for Arch
Err(anyhow::anyhow!("Pacman backend not yet implemented")) Err(anyhow::anyhow!("Pacman backend not yet implemented"))
@ -705,4 +1208,55 @@ mod tests {
let json = serde_json::to_string(&status).unwrap(); let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("Installed")); 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. **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 **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 ## 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. **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. **Correction:** Removed ALL restrictive sandbox settings at once after understanding that package management requires full system access.