From 02760e5864cc9d5bf7df8980004312179f483eab Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 14 Aug 2026 14:28:08 +0200 Subject: [PATCH] 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) --- .dockerignore | 4 + .github/dependabot.yml | 21 +++++ .github/workflows/ci.yml | 80 ++++++++++++++++ .github/workflows/codeql.yml | 43 +++++++++ .gitignore | 8 ++ .markdownlint-cli2.jsonc | 13 +++ MIRRORS | 4 + Makefile | 50 ++++++++++ README.md | 107 ++++++++++++++++++--- main.py | 111 ---------------------- pyproject.toml | 36 +++++++ src/swap_forge/__init__.py | 0 src/swap_forge/__main__.py | 6 ++ src/swap_forge/cli.py | 74 +++++++++++++++ src/swap_forge/core.py | 160 +++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/e2e/Dockerfile | 12 +++ tests/e2e/run.sh | 103 ++++++++++++++++++++ tests/integration/__init__.py | 0 tests/integration/test_cli.py | 62 ++++++++++++ tests/unit/__init__.py | 0 tests/unit/test_cli.py | 63 +++++++++++++ tests/unit/test_core.py | 173 ++++++++++++++++++++++++++++++++++ 23 files changed, 1005 insertions(+), 125 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .gitignore create mode 100644 .markdownlint-cli2.jsonc create mode 100644 MIRRORS create mode 100644 Makefile delete mode 100755 main.py create mode 100644 pyproject.toml create mode 100644 src/swap_forge/__init__.py create mode 100644 src/swap_forge/__main__.py create mode 100644 src/swap_forge/cli.py create mode 100644 src/swap_forge/core.py create mode 100644 tests/__init__.py create mode 100644 tests/e2e/Dockerfile create mode 100644 tests/e2e/run.sh create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_cli.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_core.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dc4827c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.ruff_cache +.pytest_cache +__pycache__ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e738907 --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0e3cbe --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..de10c33 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -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 }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06c26bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..4b282be --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,13 @@ +{ + "config": { + "default": true, + "MD013": false, + "MD033": false, + "MD041": false + }, + "ignores": [ + "node_modules", + ".agents", + ".claude" + ] +} diff --git a/MIRRORS b/MIRRORS new file mode 100644 index 0000000..3605fd4 --- /dev/null +++ b/MIRRORS @@ -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/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..af01760 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 501b4fd..29e728b 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,129 @@ -# SwapForge (swafo) πŸ”„ -[![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) +# 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) [![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 - **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. -- **Safety Checks:** Skips swapfile creation if an entry already exists. -- **Simple CLI Interface:** Run the script with a single command. +- **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 command with a single argument. --- ## πŸ“₯ Installation -Install SwapForge using [Kevin's Package Manager](https://github.com/kevinveenbirkenbach/package-manager) under the alias `swafo`: - ```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 one‑line error instead of a traceback. --- ## πŸš€ 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 -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 -Developed by **Kevin Veen-Birkenbach** -- πŸ“§ [kevin@veen.world](mailto:kevin@veen.world) +Developed by **Kevin Veen-Birkenbach** + +- πŸ“§ [kevin@veen.world](mailto:kevin@veen.world) - 🌐 [https://www.veen.world](https://www.veen.world) --- diff --git a/main.py b/main.py deleted file mode 100755 index 169c6c3..0000000 --- a/main.py +++ /dev/null @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..65ad3b8 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/swap_forge/__init__.py b/src/swap_forge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/swap_forge/__main__.py b/src/swap_forge/__main__.py new file mode 100644 index 0000000..04c47e7 --- /dev/null +++ b/src/swap_forge/__main__.py @@ -0,0 +1,6 @@ +import sys + +from swap_forge.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/swap_forge/cli.py b/src/swap_forge/cli.py new file mode 100644 index 0000000..054e90d --- /dev/null +++ b/src/swap_forge/cli.py @@ -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 diff --git a/src/swap_forge/core.py b/src/swap_forge/core.py new file mode 100644 index 0000000..c32558a --- /dev/null +++ b/src/swap_forge/core.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/Dockerfile b/tests/e2e/Dockerfile new file mode 100644 index 0000000..4e69b8d --- /dev/null +++ b/tests/e2e/Dockerfile @@ -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"] diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100644 index 0000000..b1a2130 --- /dev/null +++ b/tests/e2e/run.sh @@ -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" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py new file mode 100644 index 0000000..5825afb --- /dev/null +++ b/tests/integration/test_cli.py @@ -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() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..56a75ad --- /dev/null +++ b/tests/unit/test_cli.py @@ -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() diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py new file mode 100644 index 0000000..28c4388 --- /dev/null +++ b/tests/unit/test_core.py @@ -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()