mirror of
https://github.com/kevinveenbirkenbach/create-linux-swapfile.git
synced 2026-08-16 20:02:47 +00:00
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:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
.ruff_cache
|
||||||
|
.pytest_cache
|
||||||
|
__pycache__
|
||||||
21
.github/dependabot.yml
vendored
Normal file
21
.github/dependabot.yml
vendored
Normal 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
80
.github/workflows/ci.yml
vendored
Normal 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
43
.github/workflows/codeql.yml
vendored
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
13
.markdownlint-cli2.jsonc
Normal file
13
.markdownlint-cli2.jsonc
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"default": true,
|
||||||
|
"MD013": false,
|
||||||
|
"MD033": false,
|
||||||
|
"MD041": false
|
||||||
|
},
|
||||||
|
"ignores": [
|
||||||
|
"node_modules",
|
||||||
|
".agents",
|
||||||
|
".claude"
|
||||||
|
]
|
||||||
|
}
|
||||||
4
MIRRORS
Normal file
4
MIRRORS
Normal 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
50
Makefile
Normal 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
103
README.md
@@ -1,49 +1,128 @@
|
|||||||
# SwapForge (swafo) 🔄
|
# SwapForge (swap-forge) 🔄
|
||||||
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
|
||||||
|
|
||||||
|
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||||
|
|
||||||
[](LICENSE) [](https://github.com/kevinveenbirkenbach/swap-forge/stargazers)
|
[](LICENSE) [](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 one‑line 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
111
main.py
@@ -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
36
pyproject.toml
Normal 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"]
|
||||||
0
src/swap_forge/__init__.py
Normal file
0
src/swap_forge/__init__.py
Normal file
6
src/swap_forge/__main__.py
Normal file
6
src/swap_forge/__main__.py
Normal 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
74
src/swap_forge/cli.py
Normal 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
160
src/swap_forge/core.py
Normal 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
0
tests/__init__.py
Normal file
12
tests/e2e/Dockerfile
Normal file
12
tests/e2e/Dockerfile
Normal 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
103
tests/e2e/run.sh
Normal 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"
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
62
tests/integration/test_cli.py
Normal file
62
tests/integration/test_cli.py
Normal 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
0
tests/unit/__init__.py
Normal file
63
tests/unit/test_cli.py
Normal file
63
tests/unit/test_cli.py
Normal 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
173
tests/unit/test_core.py
Normal 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()
|
||||||
Reference in New Issue
Block a user