feat: package as pip-installable distribution

Turns the loose main.py into the 'swap-forge' distribution with a src layout, shipping both the swap-forge and the older swafo command.

A failing swap command no longer produces a traceback: CalledProcessError is caught and reported as "'mkswap' failed with exit code 1" at exit 1, a missing binary as exit 127. Required binaries are declared per filesystem path so an ext4 host is never asked for btrfs tooling.

The fstab entry is still written last, after swapon, so a failed activation leaves /etc/fstab untouched. The e2e suite proves that byte for byte across a create, a same-size rerun and a resize cycle, with real fallocate, chmod and file sizes.

Adds unit, integration and container-based e2e tests, ruff and markdown/mermaid linting, CodeQL and Dependabot, and pins the core metadata to 2.4 so twine accepts the artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 14:28:08 +02:00
parent e1eef73ae7
commit 02760e5864
23 changed files with 1005 additions and 125 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.git
.ruff_cache
.pytest_cache
__pycache__

21
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns:
- "*"
- package-ecosystem: docker
directories:
- "/tests/e2e"
schedule:
interval: weekly
- package-ecosystem: pip
directory: /
schedule:
interval: weekly

80
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: CI
on:
push:
pull_request:
jobs:
test-and-lint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install package and tooling
shell: bash
run: |
set -euo pipefail
python -m pip install --upgrade pip
python -m pip install -e .
python -m pip install ruff
- name: Ruff (lint)
shell: bash
run: |
set -euo pipefail
ruff check .
- name: Ruff (format check)
shell: bash
run: |
set -euo pipefail
ruff format --check .
- name: Unit tests
shell: bash
run: |
set -euo pipefail
make test-unit
- name: Integration tests
shell: bash
run: |
set -euo pipefail
make test-integration
docs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Markdown and mermaid lint
shell: bash
run: |
set -euo pipefail
make lint-docs
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: End-to-end tests in a container
shell: bash
run: |
set -euo pipefail
make test-e2e

43
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: CodeQL
on:
push:
pull_request:
schedule:
- cron: "27 4 * * 1"
jobs:
analyze:
name: Check security
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
security-events: write
packages: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: python
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.pytest_cache/
.ruff_cache/
.mypy_cache/

13
.markdownlint-cli2.jsonc Normal file
View File

@@ -0,0 +1,13 @@
{
"config": {
"default": true,
"MD013": false,
"MD033": false,
"MD041": false
},
"ignores": [
"node_modules",
".agents",
".claude"
]
}

4
MIRRORS Normal file
View File

@@ -0,0 +1,4 @@
git@github.com:kevinveenbirkenbach/swap-forge.git
ssh://git@git.veen.world:2201/kevinveenbirkenbach/swap-forge.git
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/swap-forge.git
https://pypi.org/project/swap-forge/

50
Makefile Normal file
View File

@@ -0,0 +1,50 @@
SHELL := /usr/bin/env bash
.PHONY: help lint lint-docs format test test-unit test-integration test-e2e clean
PY ?= python3
E2E_IMAGE ?= swap-forge-e2e
MD_LINT_IMAGE ?= davidanson/markdownlint-cli2:latest
MERMAID_IMAGE ?= minlag/mermaid-cli:latest
export PYTHONPATH := $(CURDIR)/src
help:
@echo "Targets:"
@echo " make lint - ruff check + ruff format --check"
@echo " make lint-docs - markdownlint + mermaid diagram validation"
@echo " make format - apply ruff format"
@echo " make test - unit + integration tests"
@echo " make test-unit - unit tests only"
@echo " make test-integration - integration tests only"
@echo " make test-e2e - install and exercise the CLI in a container"
@echo " make clean - remove caches"
lint:
ruff check .
ruff format --check .
# The repository is mounted read-only: mermaid-cli writes its SVGs next to the
# output file, which must never land in the working tree.
lint-docs:
docker run --rm -v "$(CURDIR)":/workdir $(MD_LINT_IMAGE) "**/*.md"
docker run --rm -v "$(CURDIR)":/data:ro -w /tmp $(MERMAID_IMAGE) -i /data/README.md -o /tmp/out.md
format:
ruff format .
test: test-unit test-integration
test-unit:
$(PY) -m unittest discover -s tests/unit -t . -p "test_*.py"
test-integration:
$(PY) -m unittest discover -s tests/integration -t . -p "test_*.py"
test-e2e:
docker build -f tests/e2e/Dockerfile -t $(E2E_IMAGE) .
docker run --rm $(E2E_IMAGE)
clean:
rm -rf .pytest_cache .ruff_cache .mypy_cache
find . -type d -name "__pycache__" -print0 | xargs -0 -r rm -rf

103
README.md
View File

@@ -1,49 +1,128 @@
# SwapForge (swafo) 🔄 # SwapForge (swap-forge) 🔄
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![GitHub stars](https://img.shields.io/github/stars/kevinveenbirkenbach/swap-forge.svg?style=social)](https://github.com/kevinveenbirkenbach/swap-forge/stargazers) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![GitHub stars](https://img.shields.io/github/stars/kevinveenbirkenbach/swap-forge.svg?style=social)](https://github.com/kevinveenbirkenbach/swap-forge/stargazers)
SwapForge is a simple yet powerful bash script for creating and managing Linux swapfiles. Whether you need to boost system performance or add swap space to your setup, SwapForge automates the process quickly and reliably. SwapForge is a small Python CLI for creating and managing Linux swapfiles. Whether you need to boost system performance or add swap space to your setup, SwapForge automates the process quickly and reliably — and it knows the difference between btrfs and everything else.
---
## 🧭 How it works
```mermaid
flowchart TD
A["swap-forge SIZE"] --> B["parse SIZE into MiB"]
B --> B2{"parsable?"}
B2 -- no --> X["Invalid size format - exit 1"]
B2 -- yes --> C["findmnt -no FSTYPE /"]
C --> D{"root filesystem is btrfs?"}
D -- yes --> E["target: /var/swap/swapfile"]
D -- no --> F["target: /swapfile"]
E --> G["read the size of the existing swapfile"]
F --> G
G --> H{"same size already?"}
H -- yes --> Y["Skipping - exit 0"]
H -- no --> I{"a swapfile is already there?"}
I -- yes --> J["swapoff, delete it, drop its fstab line"]
I -- no --> K
J --> K{"root filesystem is btrfs?"}
K -- yes --> L["chattr +C, turn compression off, allocate with dd"]
K -- no --> M["allocate with fallocate"]
L --> N["chmod 600"]
M --> N
N --> O["mkswap"]
O --> P["swapon"]
P --> Q["append the entry to /etc/fstab"]
```
The fstab entry is written **after** activation succeeds, so a failed run never leaves a half-written fstab behind.
--- ---
## 🛠 Features ## 🛠 Features
- **Automated Swapfile Creation:** Easily create a swapfile with a specified size. - **Automated Swapfile Creation:** Easily create a swapfile with a specified size.
- **Btrfs Aware:** Disables copy-on-write and compression on the swap directory and allocates with `dd`; other filesystems use `fallocate`.
- **FSTAB Integration:** Automatically updates `/etc/fstab` to ensure the swapfile is mounted at boot. - **FSTAB Integration:** Automatically updates `/etc/fstab` to ensure the swapfile is mounted at boot.
- **Safety Checks:** Skips swapfile creation if an entry already exists. - **Safety Checks:** Skips creation when a swapfile of the correct size already exists, and recreates it when the size differs.
- **Simple CLI Interface:** Run the script with a single command. - **Simple CLI Interface:** Run the command with a single argument.
--- ---
## 📥 Installation ## 📥 Installation
Install SwapForge using [Kevin's Package Manager](https://github.com/kevinveenbirkenbach/package-manager) under the alias `swafo`:
```bash ```bash
package-manager install swafo pip install swap-forge
``` ```
This command installs SwapForge globally, making it available as `swafo` in your terminal. 🚀 pip is the single supported installation path.
The package installs **two** identical commands: `swap-forge` (primary) and `swafo` (kept for older documentation and scripts).
---
## 🔧 Requirements
- **Python 3.10+** 🐍
- **root privileges** — the tool writes `/etc/fstab` and activates swap
- Always required on `PATH`: `findmnt`, `swapoff`, `chmod`, `mkswap`, `swapon`
- On a **btrfs** root additionally: `chattr`, `btrfs`, `dd`
- On any **other** root filesystem additionally: `fallocate`
If a required command is missing, the tool exits with code `127` and a oneline error instead of a traceback.
--- ---
## 🚀 Usage ## 🚀 Usage
Run SwapForge from the command line by specifying the desired swapfile size. For example, to create a 2G swapfile: Run SwapForge by specifying the desired swapfile size. For example, to create a 2G swapfile:
```bash ```bash
swafo 2G sudo swap-forge 2G
``` ```
The script will check if a swapfile entry already exists in `/etc/fstab`. If not, it will create the swapfile, set the correct permissions, format it as swap, activate it, and append the necessary entry to `/etc/fstab`. Accepted sizes are whole numbers with an optional unit: `2048`, `2048M`, `2048MB`, `64G`, `64GB`. Without a unit the value is read as MiB.
The swapfile location follows the root filesystem:
| Root filesystem | Swapfile |
| --- | --- |
| `btrfs` | `/var/swap/swapfile` |
| anything else | `/swapfile` |
SwapForge compares the existing swapfile against the requested size, skips when they match, and otherwise removes the old one — including its `/etc/fstab` line — before creating the new one.
### Exit codes
| Code | Meaning |
| --- | --- |
| `0` | Swapfile is in place, either created or already correct. |
| `1` | Invalid size argument, or one of the swap commands failed. |
| `2` | Invalid command line arguments. |
| `127` | A required command is not installed. |
---
## 🧪 Development
```bash
make lint # ruff check + ruff format --check
make format # apply ruff format
make test # unit + integration tests
make test-unit
make test-integration
make test-e2e # install the package in a container and exercise the CLI
```
Tests run against the working tree — the `Makefile` puts `src/` on `PYTHONPATH`, so no install is needed. Every command execution is mocked and `fstab` handling is exercised against a temporary file, so the suite never touches real swap.
--- ---
## 🧑‍💻 Author ## 🧑‍💻 Author
Developed by **Kevin Veen-Birkenbach** Developed by **Kevin Veen-Birkenbach**
- 📧 [kevin@veen.world](mailto:kevin@veen.world) - 📧 [kevin@veen.world](mailto:kevin@veen.world)
- 🌐 [https://www.veen.world](https://www.veen.world) - 🌐 [https://www.veen.world](https://www.veen.world)

111
main.py
View File

@@ -1,111 +0,0 @@
#!/usr/bin/env python3
import argparse
import re
import subprocess
from pathlib import Path
FSTAB_FILE = Path("/etc/fstab")
BTRFS_SWAP_DIR = Path("/var/swap")
BTRFS_SWAP_PATH = BTRFS_SWAP_DIR / "swapfile"
DEFAULT_SWAP_PATH = Path("/swapfile")
def run(cmd, check=True, capture=False):
"""Helper to run shell commands."""
kwargs = {"check": check}
if capture:
kwargs["stdout"] = subprocess.PIPE
kwargs["stderr"] = subprocess.PIPE
kwargs["text"] = True
return subprocess.run(cmd, **kwargs)
def detect_root_fs() -> str:
result = run(["findmnt", "-no", "FSTYPE", "/"], capture=True)
return result.stdout.strip()
def parse_size_to_mib(size: str) -> int:
"""Convert '64G' or '2048M' into MiB integer."""
match = re.match(r"^(\d+)([gGmM]?[bB]?)?$", size.strip())
if not match:
raise ValueError(f"Invalid size format: {size}")
num, unit = match.groups()
num = int(num)
unit = (unit or "M").lower()
if unit in ("g", "gb"):
return num * 1024
elif unit in ("m", "mb"):
return num
else:
raise ValueError(f"Unsupported unit: {unit}")
def get_swap_size(path: Path) -> int:
"""Return swapfile size in MiB if it exists, else 0."""
if not path.exists():
return 0
return path.stat().st_size // (1024 * 1024)
def remove_swap(path: Path):
"""Disable and remove swapfile and its fstab entry."""
run(["swapoff", str(path)], check=False)
if path.exists():
path.unlink()
# remove old fstab line
if FSTAB_FILE.exists():
text = FSTAB_FILE.read_text().splitlines()
new_lines = [
line for line in text
if not re.search(rf"^{re.escape(str(path))}\s+none\s+swap", line)
]
FSTAB_FILE.write_text("\n".join(new_lines) + "\n")
def create_swap(path: Path, size_mib: int, fs: str):
if fs == "btrfs":
print(f"Creating {size_mib} MiB swapfile on btrfs at {path}")
path.parent.mkdir(parents=True, exist_ok=True)
run(["chattr", "+C", str(path.parent)], check=False)
run(["btrfs", "property", "set", "-ts", str(path.parent), "compression", "none"], check=False)
run([
"dd", "if=/dev/zero", f"of={path}", "bs=1M",
f"count={size_mib}", "status=progress"
])
else:
print(f"Creating {size_mib} MiB swapfile on {fs} at {path}")
run(["fallocate", "-l", f"{size_mib}M", str(path)])
run(["chmod", "600", str(path)])
run(["mkswap", str(path)])
run(["swapon", str(path)])
with FSTAB_FILE.open("a") as f:
f.write(f"{path} none swap sw 0 0\n")
def main():
parser = argparse.ArgumentParser(description="SwapForge Python edition")
parser.add_argument("size", help="Swapfile size (e.g. 2048M or 64G)")
args = parser.parse_args()
fs = detect_root_fs()
path = BTRFS_SWAP_PATH if fs == "btrfs" else DEFAULT_SWAP_PATH
new_size = parse_size_to_mib(args.size)
old_size = get_swap_size(path)
if old_size == new_size and old_size > 0:
print(f"Swapfile {path} already exists with correct size ({old_size} MiB). Skipping.")
return
if old_size > 0 and old_size != new_size:
print(f"Existing swapfile {path} has size {old_size} MiB, expected {new_size} MiB. Recreating...")
remove_swap(path)
create_swap(path, new_size, fs)
print("Done.")
if __name__ == "__main__":
main()

36
pyproject.toml Normal file
View File

@@ -0,0 +1,36 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "swap-forge"
version = "0.0.0"
description = "Create, resize and register a Linux swapfile, btrfs aware."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
keywords = ["swap", "swapfile", "btrfs", "linux", "cli"]
dependencies = []
[project.urls]
Homepage = "https://www.veen.world/"
Repository = "https://github.com/kevinveenbirkenbach/swap-forge"
[project.scripts]
swap-forge = "swap_forge.cli:main"
swafo = "swap_forge.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/swap_forge"]
core-metadata-version = "2.4"
[tool.hatch.build.targets.sdist]
core-metadata-version = "2.4"
[tool.ruff]
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
ignore = ["E501"]

View File

View File

@@ -0,0 +1,6 @@
import sys
from swap_forge.cli import main
if __name__ == "__main__":
sys.exit(main())

74
src/swap_forge/cli.py Normal file
View File

@@ -0,0 +1,74 @@
import argparse
import sys
from subprocess import CalledProcessError
from swap_forge.core import (
EXIT_COMMAND_FAILED,
EXIT_MISSING_BINARY,
MissingBinaryError,
create_swap,
detect_root_fs,
get_swap_size,
parse_size_to_mib,
remove_swap,
swap_path_for,
)
def build_parser():
"""Return the argument parser for the swap-forge command."""
parser = argparse.ArgumentParser(description="SwapForge Python edition")
parser.add_argument("size", help="Swapfile size (e.g. 2048M or 64G)")
return parser
def main(argv=None):
"""Create or resize the system swapfile.
Args:
argv: Argument list, defaults to sys.argv[1:].
Returns:
Process exit code.
"""
args = build_parser().parse_args(argv)
try:
new_size = parse_size_to_mib(args.size)
except ValueError as error:
print(f"Error: {error}", file=sys.stderr)
return 1
try:
fs = detect_root_fs()
path = swap_path_for(fs)
old_size = get_swap_size(path)
if old_size == new_size and old_size > 0:
print(
f"Swapfile {path} already exists with correct size "
f"({old_size} MiB). Skipping."
)
return 0
if old_size > 0:
print(
f"Existing swapfile {path} has size {old_size} MiB, "
f"expected {new_size} MiB. Recreating..."
)
remove_swap(path)
create_swap(path, new_size, fs)
except MissingBinaryError as error:
print(f"Error: {error}", file=sys.stderr)
return EXIT_MISSING_BINARY
except CalledProcessError as error:
command = error.cmd[0] if isinstance(error.cmd, list | tuple) else error.cmd
print(
f"Error: '{command}' failed with exit code {error.returncode}",
file=sys.stderr,
)
return EXIT_COMMAND_FAILED
print("Done.")
return 0

160
src/swap_forge/core.py Normal file
View File

@@ -0,0 +1,160 @@
import re
import subprocess
from pathlib import Path
FSTAB_FILE = Path("/etc/fstab")
BTRFS_SWAP_DIR = Path("/var/swap")
BTRFS_SWAP_PATH = BTRFS_SWAP_DIR / "swapfile"
DEFAULT_SWAP_PATH = Path("/swapfile")
SIZE_PATTERN = re.compile(r"^(\d+)([gGmM]?[bB]?)?$")
MIB_PER_GIB = 1024
REQUIRED_BINARIES = ("findmnt", "swapoff", "chmod", "mkswap", "swapon")
BTRFS_BINARIES = ("chattr", "btrfs", "dd")
NON_BTRFS_BINARIES = ("fallocate",)
EXIT_MISSING_BINARY = 127
EXIT_COMMAND_FAILED = 1
class MissingBinaryError(RuntimeError):
"""A required external command is not installed.
Args:
binary: Name of the missing executable.
"""
def __init__(self, binary):
super().__init__(f"required command '{binary}' is not installed")
self.binary = binary
def run(cmd, check=True, capture=False):
"""Run a command and return the completed process.
Args:
cmd: Command argument list.
check: Raise CalledProcessError when the command fails.
capture: Capture stdout and stderr as text.
"""
try:
if capture:
return subprocess.run(cmd, check=check, capture_output=True, text=True)
return subprocess.run(cmd, check=check)
except FileNotFoundError as error:
raise MissingBinaryError(cmd[0]) from error
def detect_root_fs():
"""Return the filesystem type mounted at /."""
return run(["findmnt", "-no", "FSTYPE", "/"], capture=True).stdout.strip()
def parse_size_to_mib(size):
"""Convert a size such as '64G' or '2048M' into MiB.
Args:
size: Size string with an optional G/M unit, defaulting to MiB.
"""
match = SIZE_PATTERN.match(size.strip())
if not match:
raise ValueError(f"Invalid size format: {size}")
number, unit = match.groups()
unit = (unit or "M").lower()
if unit in ("g", "gb"):
return int(number) * MIB_PER_GIB
if unit in ("m", "mb"):
return int(number)
raise ValueError(f"Unsupported unit: {unit}")
def get_swap_size(path):
"""Return the size of the swapfile in MiB, or 0 when it does not exist.
Args:
path: Path of the swapfile.
"""
if not path.exists():
return 0
return path.stat().st_size // (1024 * 1024)
def swap_fstab_line(path):
"""Return the fstab line registering the given swapfile."""
return f"{path} none swap sw 0 0"
def remove_swap(path, fstab=FSTAB_FILE):
"""Disable the swapfile and drop it plus its fstab entry.
Args:
path: Path of the swapfile.
fstab: Path of the fstab file to clean up.
"""
run(["swapoff", str(path)], check=False)
if path.exists():
path.unlink()
if not fstab.exists():
return
kept = [
line
for line in fstab.read_text().splitlines()
if not re.search(rf"^{re.escape(str(path))}\s+none\s+swap", line)
]
fstab.write_text("\n".join(kept) + "\n")
def create_swap(path, size_mib, fs, fstab=FSTAB_FILE):
"""Create, activate and register a swapfile.
Args:
path: Path of the swapfile.
size_mib: Desired size in MiB.
fs: Filesystem type of the root mount.
fstab: Path of the fstab file to append to.
"""
if fs == "btrfs":
print(f"Creating {size_mib} MiB swapfile on btrfs at {path}")
path.parent.mkdir(parents=True, exist_ok=True)
run(["chattr", "+C", str(path.parent)], check=False)
run(
[
"btrfs",
"property",
"set",
"-ts",
str(path.parent),
"compression",
"none",
],
check=False,
)
run(
[
"dd",
"if=/dev/zero",
f"of={path}",
"bs=1M",
f"count={size_mib}",
"status=progress",
]
)
else:
print(f"Creating {size_mib} MiB swapfile on {fs} at {path}")
run(["fallocate", "-l", f"{size_mib}M", str(path)])
run(["chmod", "600", str(path)])
run(["mkswap", str(path)])
run(["swapon", str(path)])
with fstab.open("a") as handle:
handle.write(swap_fstab_line(path) + "\n")
def swap_path_for(fs):
"""Return the swapfile path matching the given root filesystem type."""
return BTRFS_SWAP_PATH if fs == "btrfs" else DEFAULT_SWAP_PATH

0
tests/__init__.py Normal file
View File

12
tests/e2e/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends util-linux e2fsprogs btrfs-progs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . /src
RUN pip install --no-cache-dir .
CMD ["sh", "/src/tests/e2e/run.sh"]

103
tests/e2e/run.sh Normal file
View File

@@ -0,0 +1,103 @@
#!/bin/sh
# An unprivileged container cannot activate swap, so the run is expected to fail
# at mkswap or swapon - which of the two depends on the host's /proc/swaps.
# Everything before it - findmnt, fallocate, chmod - runs for real.
set -eu
fail() {
echo "FAIL: $1" >&2
exit 1
}
SWAPFORGE="$(command -v swap-forge)" || fail "swap-forge is not on PATH after install"
echo "ok: swap-forge installed at ${SWAPFORGE}"
command -v swafo >/dev/null || fail "the swafo alias is not on PATH after install"
echo "ok: swafo alias installed"
case "${SWAPFORGE}" in
/usr/local/bin/*) ;;
*) fail "swap-forge resolved to ${SWAPFORGE}, outside the install prefix" ;;
esac
echo "ok: resolved from the installed package, not the source tree"
for binary in findmnt swapoff chmod mkswap swapon fallocate chattr btrfs dd; do
command -v "${binary}" >/dev/null || fail "${binary} is missing from the image"
done
echo "ok: every declared binary is present"
swap-forge --help | grep -q "Swapfile size" || fail "--help does not describe the tool"
echo "ok: --help"
set +e
swap-forge >/dev/null 2>&1
status=$?
set -e
[ "${status}" -eq 2 ] || fail "a missing size exited ${status}, expected 2"
echo "ok: a missing size exits 2"
set +e
output="$(swap-forge 64T 2>&1)"
status=$?
set -e
[ "${status}" -eq 1 ] || fail "an invalid size exited ${status}, expected 1"
echo "${output}" | grep -q "Invalid size format" || fail "invalid size message wrong"
if echo "${output}" | grep -q "Traceback"; then
fail "an invalid size produced a traceback"
fi
echo "ok: an invalid size exits 1 without a traceback"
printf 'UUID=deadbeef / ext4 defaults 0 1\n' >/etc/fstab
cp /etc/fstab /tmp/fstab.before
set +e
output="$(swap-forge 8M 2>&1)"
status=$?
set -e
[ "${status}" -eq 1 ] || fail "the blocked activation exited ${status}, expected 1"
echo "${output}" | grep -q "Creating 8 MiB swapfile" || fail "the creation banner is missing"
echo "${output}" | grep -qE "'(mkswap|swapon)' failed with exit code" || fail "the blocked activation was not reported cleanly"
if echo "${output}" | grep -q "Traceback"; then
fail "the blocked activation produced a traceback"
fi
echo "ok: real fallocate and chmod ran; the blocked activation exits 1 cleanly"
[ -f /swapfile ] || fail "/swapfile was not created"
size="$(stat -c %s /swapfile)"
[ "${size}" -eq 8388608 ] || fail "/swapfile is ${size} bytes, expected 8388608"
echo "ok: /swapfile exists with exactly the requested size"
cmp -s /etc/fstab /tmp/fstab.before || fail "/etc/fstab changed although activation failed"
echo "ok: /etc/fstab is untouched when activation fails"
set +e
output="$(swap-forge 8M 2>&1)"
status=$?
set -e
[ "${status}" -eq 0 ] || fail "the idempotent rerun exited ${status}, expected 0"
echo "${output}" | grep -q "already exists with correct size (8 MiB). Skipping." \
|| fail "an existing swapfile of the right size was not detected"
echo "ok: a rerun with the same size is a no-op - the idempotency the role relies on"
set +e
output="$(swap-forge 12M 2>&1)"
status=$?
set -e
echo "${output}" | grep -q "has size 8 MiB, expected 12 MiB. Recreating..." \
|| fail "a size change did not trigger a recreation"
size="$(stat -c %s /swapfile)"
[ "${size}" -eq 12582912 ] || fail "/swapfile is ${size} bytes after resize, expected 12582912"
echo "ok: a different size really removes and recreates the swapfile"
cmp -s /etc/fstab /tmp/fstab.before || fail "/etc/fstab changed during the resize cycle"
echo "ok: /etc/fstab survived the resize cycle byte for byte"
set +e
output="$(env PATH=/nonexistent-bin "${SWAPFORGE}" 8M 2>&1)"
status=$?
set -e
[ "${status}" -eq 127 ] || fail "missing findmnt exited ${status}, expected 127"
echo "${output}" | grep -q "required command 'findmnt' is not installed" || fail "missing findmnt message wrong"
echo "ok: missing findmnt exits 127 with a clean message"
echo "ALL E2E CHECKS PASSED"

View File

View File

@@ -0,0 +1,62 @@
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SRC_DIR = REPO_ROOT / "src"
def run_cli(*args, path=None):
"""Run 'python -m swap_forge' with the working tree on PYTHONPATH.
Args:
args: Command line arguments for the CLI.
path: Replacement PATH, used to hide the findmnt binary.
"""
env = dict(os.environ)
env["PYTHONPATH"] = str(SRC_DIR)
if path is not None:
env["PATH"] = str(path)
return subprocess.run(
[sys.executable, "-m", "swap_forge", *args],
capture_output=True,
text=True,
env=env,
check=False,
)
class TestCli(unittest.TestCase):
def test_help_exits_zero(self):
result = run_cli("--help")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Swapfile size", result.stdout)
def test_missing_size_is_rejected(self):
result = run_cli()
self.assertEqual(result.returncode, 2)
self.assertIn("size", result.stderr)
def test_missing_findmnt_fails_cleanly(self):
with tempfile.TemporaryDirectory() as empty:
result = run_cli("2048M", path=empty)
self.assertEqual(result.returncode, 127)
self.assertIn("required command 'findmnt' is not installed", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_invalid_size_fails_without_a_traceback(self):
result = run_cli("64T")
self.assertEqual(result.returncode, 1)
self.assertIn("Invalid size format", result.stderr)
self.assertNotIn("Traceback", result.stderr)
if __name__ == "__main__":
unittest.main()

0
tests/unit/__init__.py Normal file
View File

63
tests/unit/test_cli.py Normal file
View File

@@ -0,0 +1,63 @@
import unittest
from pathlib import Path
from subprocess import CalledProcessError
from unittest import mock
from swap_forge.cli import main
class TestMain(unittest.TestCase):
def _run(self, argv, fs="ext4", old_size=0):
with (
mock.patch("swap_forge.cli.detect_root_fs", return_value=fs),
mock.patch("swap_forge.cli.swap_path_for", return_value=Path("/swapfile")),
mock.patch("swap_forge.cli.get_swap_size", return_value=old_size),
mock.patch("swap_forge.cli.remove_swap") as remove,
mock.patch("swap_forge.cli.create_swap") as create,
):
exit_code = main(argv)
return exit_code, remove, create
def test_creates_a_missing_swapfile(self):
exit_code, remove, create = self._run(["2048M"])
remove.assert_not_called()
create.assert_called_once_with(Path("/swapfile"), 2048, "ext4")
self.assertEqual(exit_code, 0)
def test_matching_size_is_left_alone(self):
exit_code, remove, create = self._run(["2048M"], old_size=2048)
remove.assert_not_called()
create.assert_not_called()
self.assertEqual(exit_code, 0)
def test_wrong_size_is_recreated(self):
exit_code, remove, create = self._run(["4G"], old_size=2048)
remove.assert_called_once_with(Path("/swapfile"))
create.assert_called_once_with(Path("/swapfile"), 4096, "ext4")
self.assertEqual(exit_code, 0)
def test_failing_command_reports_cleanly(self):
with (
mock.patch("swap_forge.cli.detect_root_fs", return_value="ext4"),
mock.patch("swap_forge.cli.swap_path_for", return_value=Path("/swapfile")),
mock.patch("swap_forge.cli.get_swap_size", return_value=0),
mock.patch("swap_forge.cli.create_swap") as create,
):
create.side_effect = CalledProcessError(255, ["swapon", "/swapfile"])
exit_code = main(["2048M"])
self.assertEqual(exit_code, 1)
def test_invalid_size_exits_non_zero(self):
with mock.patch("swap_forge.cli.detect_root_fs") as detect:
exit_code = main(["64T"])
detect.assert_not_called()
self.assertEqual(exit_code, 1)
if __name__ == "__main__":
unittest.main()

173
tests/unit/test_core.py Normal file
View File

@@ -0,0 +1,173 @@
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from swap_forge.core import (
BTRFS_BINARIES,
BTRFS_SWAP_PATH,
DEFAULT_SWAP_PATH,
NON_BTRFS_BINARIES,
REQUIRED_BINARIES,
MissingBinaryError,
create_swap,
get_swap_size,
parse_size_to_mib,
remove_swap,
run,
swap_fstab_line,
swap_path_for,
)
class TestRequiredBinaries(unittest.TestCase):
def test_declares_every_external_command_the_package_calls(self):
declared = (
set(REQUIRED_BINARIES) | set(BTRFS_BINARIES) | set(NON_BTRFS_BINARIES)
)
self.assertEqual(
declared,
{
"findmnt",
"swapoff",
"chmod",
"mkswap",
"swapon",
"chattr",
"btrfs",
"dd",
"fallocate",
},
)
def test_filesystem_specific_binaries_are_not_declared_as_always_required(self):
self.assertFalse(set(REQUIRED_BINARIES) & set(BTRFS_BINARIES))
self.assertFalse(set(REQUIRED_BINARIES) & set(NON_BTRFS_BINARIES))
def test_missing_binary_is_reported_with_its_name(self):
with mock.patch("swap_forge.core.subprocess.run") as subprocess_run:
subprocess_run.side_effect = FileNotFoundError
with self.assertRaises(MissingBinaryError) as caught:
run(["mkswap", "/swapfile"])
self.assertEqual(caught.exception.binary, "mkswap")
class TestParseSizeToMib(unittest.TestCase):
def test_gibibytes(self):
for value in ("64G", "64g", "64GB", " 64G "):
with self.subTest(value=value):
self.assertEqual(parse_size_to_mib(value), 65536)
def test_mebibytes(self):
for value in ("2048M", "2048m", "2048MB"):
with self.subTest(value=value):
self.assertEqual(parse_size_to_mib(value), 2048)
def test_bare_number_is_mebibytes(self):
self.assertEqual(parse_size_to_mib("512"), 512)
def test_invalid_format(self):
for value in ("", "abc", "64T", "-5G", "6.5G"):
with self.subTest(value=value), self.assertRaises(ValueError):
parse_size_to_mib(value)
def test_unsupported_unit(self):
with self.assertRaises(ValueError):
parse_size_to_mib("64b")
class TestSwapPathFor(unittest.TestCase):
def test_btrfs_uses_the_dedicated_subvolume_path(self):
self.assertEqual(swap_path_for("btrfs"), BTRFS_SWAP_PATH)
def test_other_filesystems_use_the_root_swapfile(self):
self.assertEqual(swap_path_for("ext4"), DEFAULT_SWAP_PATH)
class TestGetSwapSize(unittest.TestCase):
def test_missing_file_is_zero(self):
self.assertEqual(get_swap_size(Path("/nonexistent/swapfile")), 0)
def test_reports_size_in_mebibytes(self):
with tempfile.TemporaryDirectory() as base:
path = Path(base) / "swapfile"
path.write_bytes(b"\0" * (3 * 1024 * 1024))
self.assertEqual(get_swap_size(path), 3)
class TestSwapFstabLine(unittest.TestCase):
def test_line_shape(self):
self.assertEqual(
swap_fstab_line(Path("/swapfile")), "/swapfile none swap sw 0 0"
)
class TestRemoveSwap(unittest.TestCase):
def test_drops_file_and_fstab_entry(self):
with tempfile.TemporaryDirectory() as base:
swapfile = Path(base) / "swapfile"
swapfile.write_bytes(b"\0")
fstab = Path(base) / "fstab"
fstab.write_text(
f"UUID=abc / ext4 defaults 0 1\n{swapfile} none swap sw 0 0\n"
)
with mock.patch("swap_forge.core.run"):
remove_swap(swapfile, fstab=fstab)
self.assertFalse(swapfile.exists())
self.assertEqual(fstab.read_text(), "UUID=abc / ext4 defaults 0 1\n")
def test_keeps_unrelated_fstab_entries(self):
with tempfile.TemporaryDirectory() as base:
swapfile = Path(base) / "swapfile"
fstab = Path(base) / "fstab"
fstab.write_text("/other none swap sw 0 0\n")
with mock.patch("swap_forge.core.run"):
remove_swap(swapfile, fstab=fstab)
self.assertEqual(fstab.read_text(), "/other none swap sw 0 0\n")
def test_missing_fstab_is_tolerated(self):
with tempfile.TemporaryDirectory() as base:
with mock.patch("swap_forge.core.run"):
remove_swap(Path(base) / "swapfile", fstab=Path(base) / "fstab")
class TestCreateSwap(unittest.TestCase):
def test_non_btrfs_uses_fallocate_and_appends_to_fstab(self):
with tempfile.TemporaryDirectory() as base:
swapfile = Path(base) / "swapfile"
fstab = Path(base) / "fstab"
fstab.write_text("")
with mock.patch("swap_forge.core.run") as run:
create_swap(swapfile, 2048, "ext4", fstab=fstab)
commands = [call.args[0][0] for call in run.call_args_list]
self.assertEqual(commands, ["fallocate", "chmod", "mkswap", "swapon"])
self.assertEqual(fstab.read_text(), f"{swapfile} none swap sw 0 0\n")
def test_btrfs_disables_copy_on_write_and_uses_dd(self):
with tempfile.TemporaryDirectory() as base:
swapfile = Path(base) / "swap" / "swapfile"
fstab = Path(base) / "fstab"
fstab.write_text("")
with mock.patch("swap_forge.core.run") as run:
create_swap(swapfile, 1024, "btrfs", fstab=fstab)
commands = [call.args[0][0] for call in run.call_args_list]
self.assertEqual(
commands, ["chattr", "btrfs", "dd", "chmod", "mkswap", "swapon"]
)
self.assertTrue(swapfile.parent.is_dir())
if __name__ == "__main__":
unittest.main()