euri-mklive: universal CLI ISO builder
- any distro from any distro (debian, ubuntu, arch, artix, void, alpine, fedora) - YAML config for distro, arch, init, packages, users, services, bootloader - each distro backend knows how to bootstrap itself - squashfs + xorriso for ISO generation - sexy CLI with banner and colored output - examples for Artix+Euri and Project Horizon
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# euri-mklive
|
||||
|
||||
universal CLI iso builder. any distro, from any distro.
|
||||
|
||||
build a debian iso on arch. an arch iso on fedora. an artix iso on ubuntu. doesn't matter.
|
||||
|
||||
## install
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
# or
|
||||
pipx install .
|
||||
```
|
||||
|
||||
## usage
|
||||
|
||||
```bash
|
||||
# see what's supported
|
||||
euri-mklive supported
|
||||
|
||||
# build from a config
|
||||
euri-mklive build my-config.yaml -o ./output
|
||||
```
|
||||
|
||||
## config
|
||||
|
||||
yaml config that defines everything:
|
||||
|
||||
```yaml
|
||||
distro: artix # debian | ubuntu | arch | artix | void | alpine | fedora
|
||||
arch: x86_64
|
||||
init: dinit # dinit | openrc | runit | s6
|
||||
|
||||
hostname: mydistro
|
||||
|
||||
iso:
|
||||
name: My Distro
|
||||
version: "1.0"
|
||||
label: MYDISTRO
|
||||
|
||||
users:
|
||||
- name: user
|
||||
shell: /bin/bash
|
||||
groups: [wheel]
|
||||
|
||||
packages:
|
||||
- linux
|
||||
- plasma-desktop
|
||||
- nano
|
||||
- git
|
||||
|
||||
services:
|
||||
- connmand
|
||||
- sddm
|
||||
```
|
||||
|
||||
## supported distros
|
||||
|
||||
| distro | bootstrap tool | init systems |
|
||||
|--------|---------------|--------------|
|
||||
| debian | debootstrap | systemd |
|
||||
| ubuntu | debootstrap | systemd |
|
||||
| arch | pacstrap | systemd |
|
||||
| artix | basestrap | openrc, runit, s6, dinit |
|
||||
| void | xbps-install | runit, dinit |
|
||||
| alpine | apk | openrc |
|
||||
| fedora | dnf | systemd |
|
||||
|
||||
## how it works
|
||||
|
||||
1. reads your yaml config
|
||||
2. bootstraps the target distro into a temporary rootfs
|
||||
3. installs your packages
|
||||
4. configures users, services, hostname
|
||||
5. installs the bootloader
|
||||
6. squashes the rootfs into an ISO
|
||||
7. done.
|
||||
|
||||
## license
|
||||
|
||||
GPL-2.0
|
||||
@@ -0,0 +1,3 @@
|
||||
"""euri-mklive: Universal CLI ISO builder — any distro, from any distro."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""euri-mklive — Universal CLI ISO builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from . import __version__
|
||||
from .config import load
|
||||
from .rootfs import build, cleanup
|
||||
from .iso import generate
|
||||
from .distros import REGISTRY
|
||||
|
||||
BANNER = r"""
|
||||
\033[1;36m
|
||||
██╗███╗ ██╗████████╗██╗ ██████╗
|
||||
██║████╗ ██║╚══██╔══╝██║ ██╔══██╗
|
||||
██║██╔██╗ ██║ ██║ ██║ ██████╔╝
|
||||
██║██║╚██╗██║ ██║ ██║ ██╔══██╗
|
||||
██║██║ ╚████║ ██║ ███████╗██║ ██║
|
||||
╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
|
||||
\033[0m
|
||||
\033[1m universal iso builder\033[0m
|
||||
\033[90m any distro, from any distro\033[0m
|
||||
"""
|
||||
|
||||
SUPPORTED = ", ".join(sorted(REGISTRY.keys()))
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.version_option(__version__, prog_name="euri-mklive")
|
||||
@click.pass_context
|
||||
def cli(ctx: click.Context) -> None:
|
||||
"""\033[1mBuild bootable ISOs for any Linux distribution.\033[0m"""
|
||||
if ctx.invoked_subcommand is None:
|
||||
click.echo(BANNER)
|
||||
click.echo(f" \033[90msupported distros:\033[0m {SUPPORTED}")
|
||||
click.echo()
|
||||
click.echo(ctx.get_help())
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("config", type=click.Path(exists=True, path_type=Path))
|
||||
@click.option("-o", "--output", type=click.Path(path_type=Path), default=".", help="Output directory")
|
||||
@click.option("--keep-rootfs", is_flag=True, help="Don't remove rootfs after ISO generation")
|
||||
def build_iso(config: Path, output: Path, keep_rootfs: bool) -> None:
|
||||
"""Build an ISO from a YAML config file."""
|
||||
click.echo(BANNER)
|
||||
|
||||
cfg = load(config)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
distro = cfg["distro"]
|
||||
arch = cfg["arch"]
|
||||
init = cfg.get("init", "dinit")
|
||||
pkgs = len(cfg.get("packages", []))
|
||||
|
||||
click.echo(f" \033[36mdistro:\033[0m {distro}")
|
||||
click.echo(f" \033[36march:\033[0m {arch}")
|
||||
click.echo(f" \033[36minit:\033[0m {init}")
|
||||
click.echo(f" \033[36mpackages:\033[0m {pkgs}")
|
||||
click.echo()
|
||||
|
||||
try:
|
||||
rootfs = build(cfg, output)
|
||||
iso = generate(rootfs, cfg, output)
|
||||
|
||||
if not keep_rootfs:
|
||||
cleanup(rootfs)
|
||||
click.echo(f"\n\033[90mRootfs cleaned up.\033[0m")
|
||||
|
||||
click.echo(f"\n\033[1;32mDone! ISO ready at {iso}\033[0m")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"\n\033[1;31mError: {e}\033[0m", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
def supported() -> None:
|
||||
"""List all supported distros."""
|
||||
click.echo(BANNER)
|
||||
click.echo(" \033[1mSupported distros:\033[0m")
|
||||
for name, cls in sorted(REGISTRY.items()):
|
||||
click.echo(f" \033[36m{name:<15}\033[0m {cls.__doc__ or ''}")
|
||||
click.echo()
|
||||
click.echo(" \033[90mEach distro can be built FROM any other distro.\033[0m")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""YAML config loader and validator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REQUIRED = {"distro", "arch"}
|
||||
|
||||
|
||||
def load(path: Path) -> dict[str, Any]:
|
||||
"""Load and validate a YAML config file."""
|
||||
with open(path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(cfg, dict):
|
||||
raise ValueError(f"Config must be a YAML mapping, got {type(cfg).__name__}")
|
||||
|
||||
missing = REQUIRED - cfg.keys()
|
||||
if missing:
|
||||
raise ValueError(f"Missing required fields: {', '.join(sorted(missing))}")
|
||||
|
||||
# defaults
|
||||
cfg.setdefault("hostname", "euri")
|
||||
cfg.setdefault("users", [])
|
||||
cfg.setdefault("services", [])
|
||||
cfg.setdefault("packages", [])
|
||||
cfg.setdefault("init", "dinit")
|
||||
cfg.setdefault("bootloader", "grub")
|
||||
cfg.setdefault("iso", {})
|
||||
|
||||
return cfg
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Distro backends — each knows how to bootstrap itself."""
|
||||
|
||||
from .base import Distro
|
||||
from .debian import Debian
|
||||
from .arch import Arch
|
||||
from .void import Void
|
||||
from .alpine import Alpine
|
||||
from .fedora import Fedora
|
||||
|
||||
REGISTRY: dict[str, type[Distro]] = {
|
||||
"debian": Debian,
|
||||
"ubuntu": Debian,
|
||||
"arch": Arch,
|
||||
"artix": Arch,
|
||||
"void": Void,
|
||||
"alpine": Alpine,
|
||||
"fedora": Fedora,
|
||||
"rocky": Fedora,
|
||||
"almalinux": Fedora,
|
||||
}
|
||||
|
||||
|
||||
def get(name: str) -> type[Distro]:
|
||||
cls = REGISTRY.get(name.lower())
|
||||
if cls is None:
|
||||
raise ValueError(f"Unknown distro: {name!r}. Available: {', '.join(sorted(REGISTRY))}")
|
||||
return cls
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Alpine Linux backend — uses apk with --root."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base import Distro
|
||||
|
||||
|
||||
class Alpine(Distro):
|
||||
name = "alpine"
|
||||
host_deps = ["apk-tools", "squashfs-tools", "xorriso", "mtools", "dosfstools"]
|
||||
|
||||
MIRROR = "https://dl-cdn.alpinelinux.org/alpine/latest-stable"
|
||||
|
||||
def bootstrap(self, rootfs: Path, config: dict) -> None:
|
||||
mirror = config.get("mirror", self.MIRROR)
|
||||
branch = config.get("suite", "edge")
|
||||
init = config.get("init", "openrc")
|
||||
|
||||
cmd = [
|
||||
"apk",
|
||||
"-X", f"{mirror}/{branch}/main",
|
||||
"-U",
|
||||
"--allow-untrusted",
|
||||
"--root", str(rootfs),
|
||||
"--initdb",
|
||||
"add",
|
||||
"alpine-base",
|
||||
"linux-lts",
|
||||
"linux-firmware",
|
||||
init,
|
||||
*config.get("base_packages", []),
|
||||
]
|
||||
self._run(cmd)
|
||||
|
||||
def install_packages(self, rootfs: Path, packages: list[str]) -> None:
|
||||
if not packages:
|
||||
return
|
||||
self._run_chroot(rootfs, ["apk", "add", *packages])
|
||||
|
||||
def configure_base(self, rootfs: Path, config: dict) -> None:
|
||||
hostname = config.get("hostname", "euri")
|
||||
(rootfs / "etc/hostname").write_text(f"{hostname}\n")
|
||||
|
||||
for user in config.get("users", []):
|
||||
name = user["name"]
|
||||
shell = user.get("shell", "/bin/bash")
|
||||
self._run_chroot(rootfs, ["adduser", "-D", "-s", shell, name])
|
||||
if "password" in user:
|
||||
self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"])
|
||||
for group in user.get("groups", []):
|
||||
self._run_chroot(rootfs, ["adduser", name, group])
|
||||
|
||||
init = config.get("init", "openrc")
|
||||
for svc in config.get("services", []):
|
||||
self._run_chroot(rootfs, ["rc-update", "add", svc, "default"])
|
||||
|
||||
def install_bootloader(self, rootfs: Path, config: dict) -> None:
|
||||
self._run_chroot(rootfs, ["apk", "add", "grub", "grub-efi", "os-prober"])
|
||||
self._run_chroot(rootfs, ["grub-install", "--target=i386-pc", "/dev/sda"])
|
||||
self._run_chroot(rootfs, ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi"])
|
||||
self._run_chroot(rootfs, ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"])
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Arch/Artix backend — uses pacstrap (Arch) or basestrap (Artix)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base import Distro
|
||||
|
||||
|
||||
class Arch(Distro):
|
||||
name = "arch"
|
||||
host_deps = ["squashfs-tools", "xorriso", "mtools", "dosfstools"]
|
||||
|
||||
INITS = {"openrc", "runit", "s6", "dinit"}
|
||||
|
||||
def __init__(self, family: str = "arch"):
|
||||
self.family = family
|
||||
|
||||
def bootstrap(self, rootfs: Path, config: dict) -> None:
|
||||
init = config.get("init", "dinit")
|
||||
base_pkgs = ["base", "base-devel", f"{init}", *config.get("base_packages", [])]
|
||||
|
||||
if self.family == "artix":
|
||||
# basestrap for Artix
|
||||
elogind_pkg = f"elogind-{init}"
|
||||
cmd = ["basestrap", "-i", str(rootfs), *base_pkgs, elogind_pkg]
|
||||
else:
|
||||
# pacstrap for Arch
|
||||
cmd = ["pacstrap", "-K", str(rootfs), *base_pkgs]
|
||||
|
||||
self._run(cmd)
|
||||
|
||||
def install_packages(self, rootfs: Path, packages: list[str]) -> None:
|
||||
if not packages:
|
||||
return
|
||||
self._run_chroot(rootfs, ["pacman", "-Syu", "--noconfirm", *packages])
|
||||
|
||||
def configure_base(self, rootfs: Path, config: dict) -> None:
|
||||
hostname = config.get("hostname", "euri")
|
||||
(rootfs / "etc/hostname").write_text(f"{hostname}\n")
|
||||
|
||||
# hosts file
|
||||
hosts = (
|
||||
"127.0.0.1\tlocalhost\n"
|
||||
"::1\t\tlocalhost\n"
|
||||
f"127.0.1.1\t{hostname}.localdomain\t{hostname}\n"
|
||||
)
|
||||
(rootfs / "etc/hosts").write_text(hosts)
|
||||
|
||||
for user in config.get("users", []):
|
||||
name = user["name"]
|
||||
shell = user.get("shell", "/bin/bash")
|
||||
self._run_chroot(rootfs, ["useradd", "-m", "-s", shell, name])
|
||||
if "password" in user:
|
||||
self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"])
|
||||
for group in user.get("groups", []):
|
||||
self._run_chroot(rootfs, ["usermod", "-aG", group, name])
|
||||
|
||||
init = config.get("init", "dinit")
|
||||
|
||||
# enable services per init system
|
||||
for svc in config.get("services", []):
|
||||
if self.family == "artix":
|
||||
if init == "openrc":
|
||||
self._run_chroot(rootfs, ["rc-update", "add", svc])
|
||||
elif init == "runit":
|
||||
self._run_chroot(rootfs, ["ln", "-s", f"/etc/runit/sv/{svc}", "/etc/runit/runsvdir/default"])
|
||||
elif init == "s6":
|
||||
self._run_chroot(rootfs, ["touch", f"/etc/s6/adminsv/default/contents.d/{svc}"])
|
||||
elif init == "dinit":
|
||||
self._run_chroot(rootfs, ["ln", "-s", f"../{svc}", "/etc/dinit.d/boot.d/"])
|
||||
else:
|
||||
self._run_chroot(rootfs, ["systemctl", "enable", svc])
|
||||
|
||||
def install_bootloader(self, rootfs: Path, config: dict) -> None:
|
||||
init = config.get("init", "dinit")
|
||||
self._run_chroot(rootfs, ["pacman", "-S", "--noconfirm", "grub", "efibootmgr", "os-prober"])
|
||||
self._run_chroot(rootfs, ["grub-install", "--target=i386-pc", "/dev/sda"])
|
||||
self._run_chroot(rootfs, ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi", "--bootloader-id=grub"])
|
||||
self._run_chroot(rootfs, ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"])
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Base class for distro backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Distro(ABC):
|
||||
"""Base class every distro backend must implement."""
|
||||
|
||||
name: str
|
||||
# host packages needed to bootstrap (e.g. debootstrap, pacstrap)
|
||||
host_deps: list[str]
|
||||
|
||||
@abstractmethod
|
||||
def bootstrap(self, rootfs: Path, config: dict) -> None:
|
||||
"""Create the rootfs by installing the base system."""
|
||||
|
||||
@abstractmethod
|
||||
def install_packages(self, rootfs: Path, packages: list[str]) -> None:
|
||||
"""Install packages into an existing rootfs."""
|
||||
|
||||
@abstractmethod
|
||||
def configure_base(self, rootfs: Path, config: dict) -> None:
|
||||
"""Write hostname, fstab, locale, users, services, etc."""
|
||||
|
||||
@abstractmethod
|
||||
def install_bootloader(self, rootfs: Path, config: dict) -> None:
|
||||
"""Install and configure the bootloader inside the rootfs."""
|
||||
|
||||
@staticmethod
|
||||
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
||||
print(f" \033[90m$\033[0m \033[1m{' '.join(cmd)}\033[0m")
|
||||
return subprocess.run(cmd, check=True, **kw)
|
||||
|
||||
@staticmethod
|
||||
def _run_chroot(rootfs: Path, cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
||||
full = ["chroot", str(rootfs), "/bin/sh", "-c", " ".join(cmd)]
|
||||
print(f" \033[90m$\033[0m \033[33m[chroot]\033[0m \033[1m{' '.join(cmd)}\033[0m")
|
||||
return subprocess.run(full, check=True, **kw)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Debian/Ubuntu backend — uses debootstrap."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base import Distro
|
||||
|
||||
|
||||
class Debian(Distro):
|
||||
name = "debian"
|
||||
host_deps = ["debootstrap", "squashfs-tools", "xorriso", "mtools", "dosfstools"]
|
||||
|
||||
SUITES = {
|
||||
"debian": "bookworm",
|
||||
"ubuntu": "noble",
|
||||
}
|
||||
|
||||
MIRRORS = {
|
||||
"debian": "http://deb.debian.org/debian",
|
||||
"ubuntu": "http://archive.ubuntu.com/ubuntu",
|
||||
}
|
||||
|
||||
def __init__(self, family: str = "debian"):
|
||||
self.family = family
|
||||
|
||||
def bootstrap(self, rootfs: Path, config: dict) -> None:
|
||||
suite = config.get("suite", self.SUITES.get(self.family, "bookworm"))
|
||||
mirror = config.get("mirror", self.MIRRORS.get(self.family))
|
||||
variant = config.get("variant", "minbase")
|
||||
|
||||
cmd = [
|
||||
"debootstrap",
|
||||
"--variant", variant,
|
||||
"--include", ",".join(config.get("base_packages", ["base-files", "apt"])),
|
||||
"--arch", config.get("arch", "amd64"),
|
||||
suite,
|
||||
str(rootfs),
|
||||
mirror,
|
||||
]
|
||||
self._run(cmd)
|
||||
|
||||
def install_packages(self, rootfs: Path, packages: list[str]) -> None:
|
||||
if not packages:
|
||||
return
|
||||
self._run_chroot(rootfs, ["apt-get", "update"])
|
||||
self._run_chroot(rootfs, ["apt-get", "install", "-y", *packages])
|
||||
|
||||
def configure_base(self, rootfs: Path, config: dict) -> None:
|
||||
hostname = config.get("hostname", "euri")
|
||||
(rootfs / "etc/hostname").write_text(f"{hostname}\n")
|
||||
|
||||
for user in config.get("users", []):
|
||||
name = user["name"]
|
||||
shell = user.get("shell", "/bin/bash")
|
||||
self._run_chroot(rootfs, ["useradd", "-m", "-s", shell, name])
|
||||
if "password" in user:
|
||||
self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"])
|
||||
for group in user.get("groups", []):
|
||||
self._run_chroot(rootfs, ["usermod", "-aG", group, name])
|
||||
|
||||
for svc in config.get("services", []):
|
||||
self._run_chroot(rootfs, ["systemctl", "enable", svc])
|
||||
|
||||
def install_bootloader(self, rootfs: Path, config: dict) -> None:
|
||||
self._run_chroot(rootfs, ["apt-get", "install", "-y", "grub-pc", "grub-efi-amd64"])
|
||||
self._run_chroot(rootfs, ["grub-install", "--target=i386-pc", "/dev/sda"])
|
||||
self._run_chroot(rootfs, ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi"])
|
||||
self._run_chroot(rootfs, ["update-grub"])
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Fedora/Rocky/Alma backend — uses dnf --installroot."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base import Distro
|
||||
|
||||
|
||||
class Fedora(Distro):
|
||||
name = "fedora"
|
||||
host_deps = ["dnf", "squashfs-tools", "xorriso", "mtools", "dosfstools"]
|
||||
|
||||
GROUPS = {
|
||||
"fedora": "@core",
|
||||
"rocky": "@core",
|
||||
"almalinux": "@core",
|
||||
}
|
||||
|
||||
def __init__(self, family: str = "fedora"):
|
||||
self.family = family
|
||||
|
||||
def bootstrap(self, rootfs: Path, config: dict) -> None:
|
||||
release = config.get("suite", "40")
|
||||
group = self.GROUPS.get(self.family, "@core")
|
||||
|
||||
cmd = [
|
||||
"dnf",
|
||||
"--installroot", str(rootfs),
|
||||
"-y",
|
||||
f"--releasever={release}",
|
||||
"install",
|
||||
group,
|
||||
"kernel",
|
||||
"grub2-efi-x64",
|
||||
*config.get("base_packages", []),
|
||||
]
|
||||
self._run(cmd)
|
||||
|
||||
def install_packages(self, rootfs: Path, packages: list[str]) -> None:
|
||||
if not packages:
|
||||
return
|
||||
self._run_chroot(rootfs, ["dnf", "-y", "install", *packages])
|
||||
|
||||
def configure_base(self, rootfs: Path, config: dict) -> None:
|
||||
hostname = config.get("hostname", "euri")
|
||||
(rootfs / "etc/hostname").write_text(f"{hostname}\n")
|
||||
|
||||
for user in config.get("users", []):
|
||||
name = user["name"]
|
||||
shell = user.get("shell", "/bin/bash")
|
||||
self._run_chroot(rootfs, ["useradd", "-m", "-s", shell, name])
|
||||
if "password" in user:
|
||||
self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"])
|
||||
for group in user.get("groups", []):
|
||||
self._run_chroot(rootfs, ["usermod", "-aG", group, name])
|
||||
|
||||
for svc in config.get("services", []):
|
||||
self._run_chroot(rootfs, ["systemctl", "enable", svc])
|
||||
|
||||
def install_bootloader(self, rootfs: Path, config: dict) -> None:
|
||||
self._run_chroot(rootfs, ["grub2-install", "--target=i386-pc", "/dev/sda"])
|
||||
self._run_chroot(rootfs, ["grub2-install", "--target=x86_64-efi", "--efi-directory=/boot/efi"])
|
||||
self._run_chroot(rootfs, ["grub2-mkconfig", "-o", "/boot/grub2/grub.cfg"])
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Void Linux backend — uses xbps-install with a rootfs tarball or chroot."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base import Distro
|
||||
|
||||
|
||||
class Void(Distro):
|
||||
name = "void"
|
||||
host_deps = ["xbps-install", "squashfs-tools", "xorriso", "mtools", "dosfstools"]
|
||||
|
||||
REPOS = {
|
||||
"glibc": "https://repo-default.voidlinux.org/current",
|
||||
"musl": "https://repo-default.voidlinux.org/current",
|
||||
}
|
||||
|
||||
def bootstrap(self, rootfs: Path, config: dict) -> None:
|
||||
libc = config.get("libc", "musl")
|
||||
arch = config.get("arch", "x86_64")
|
||||
repo = config.get("mirror", self.REPOS[libc])
|
||||
init = config.get("init", "dinit")
|
||||
|
||||
# base packages — swap runit for dinit if requested
|
||||
base = ["base-system"]
|
||||
if init == "dinit":
|
||||
# Void ships runit in base-system; we'll replace later
|
||||
base.append("dinit")
|
||||
|
||||
cmd = [
|
||||
"xbps-install",
|
||||
"-S",
|
||||
"-r", str(rootfs),
|
||||
"-R", repo,
|
||||
*base,
|
||||
]
|
||||
env = {**config.get("env", {}), "XBPS_ARCH": f"{arch}-{libc}"}
|
||||
self._run(cmd, env=env)
|
||||
|
||||
# swap runit for dinit
|
||||
if init == "dinit":
|
||||
self._run_chroot(rootfs, ["xbps-remove", "-y", "runit"])
|
||||
self._run_chroot(rootfs, ["ln", "-s", "/etc/dinit.d/boot.d", "/etc/runit/runsvdir/default"])
|
||||
|
||||
def install_packages(self, rootfs: Path, packages: list[str]) -> None:
|
||||
if not packages:
|
||||
return
|
||||
self._run_chroot(rootfs, ["xbps-install", "-Sy", *packages])
|
||||
|
||||
def configure_base(self, rootfs: Path, config: dict) -> None:
|
||||
hostname = config.get("hostname", "euri")
|
||||
(rootfs / "etc/hostname").write_text(f"{hostname}\n")
|
||||
|
||||
for user in config.get("users", []):
|
||||
name = user["name"]
|
||||
shell = user.get("shell", "/bin/bash")
|
||||
self._run_chroot(rootfs, ["useradd", "-m", "-s", shell, name])
|
||||
if "password" in user:
|
||||
self._run_chroot(rootfs, ["bash", "-c", f"echo '{name}:{user[\"password\"]}' | chpasswd"])
|
||||
for group in user.get("groups", []):
|
||||
self._run_chroot(rootfs, ["usermod", "-aG", group, name])
|
||||
|
||||
init = config.get("init", "dinit")
|
||||
for svc in config.get("services", []):
|
||||
if init == "dinit":
|
||||
self._run_chroot(rootfs, ["ln", "-s", f"/etc/dinit.d/{svc}", "/etc/dinit.d/boot.d/"])
|
||||
else:
|
||||
self._run_chroot(rootfs, ["ln", "-s", f"/etc/runit/sv/{svc}", "/etc/runit/runsvdir/default"])
|
||||
|
||||
def install_bootloader(self, rootfs: Path, config: dict) -> None:
|
||||
self._run_chroot(rootfs, ["xbps-install", "-Sy", "grub", "grub-x86_64-efi", "efibootmgr"])
|
||||
self._run_chroot(rootfs, ["grub-install", "--target=i386-pc", "/dev/sda"])
|
||||
self._run_chroot(rootfs, ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi", "--bootloader-id=Void"])
|
||||
self._run_chroot(rootfs, ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"])
|
||||
@@ -0,0 +1,83 @@
|
||||
"""ISO generator — squashes rootfs and creates bootable ISO."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def generate(rootfs: Path, config: dict, output: Path) -> Path:
|
||||
"""Generate a bootable ISO from a rootfs."""
|
||||
iso_cfg = config.get("iso", {})
|
||||
label = iso_cfg.get("label", "EURI")
|
||||
name = iso_cfg.get("name", "Euri Linux")
|
||||
version = iso_cfg.get("version", "1.0")
|
||||
|
||||
print(f"\n\033[1;36m==>\033[0m \033[1mGenerating ISO: {name} {version}\033[0m")
|
||||
|
||||
workdir = Path(tempfile.mkdtemp(prefix="mklive-iso-"))
|
||||
iso_dir = workdir / "iso"
|
||||
iso_dir.mkdir()
|
||||
|
||||
# copy rootfs into ISO staging
|
||||
_run(["cp", "-a", str(rootfs) + "/.", str(iso_dir)])
|
||||
|
||||
# squashfs
|
||||
squash = workdir / "filesystem.squashfs"
|
||||
print(f"\033[1;36m==>\033[0m \033[1mCreating squashfs image...\033[0m")
|
||||
_run(["mksquashfs", str(iso_dir), str(squash), "-comp", "xz", "-b", "1M"])
|
||||
|
||||
# create ISO layout
|
||||
live_dir = iso_dir / "boot" / "x86_64"
|
||||
live_dir.mkdir(parents=True, exist_ok=True)
|
||||
_run(["cp", str(squash), str(live_dir / "filesystem.squashfs")])
|
||||
|
||||
# EFI boot
|
||||
efi_dir = iso_dir / "EFI" / "BOOT"
|
||||
efi_dir.mkdir(parents=True, exist_ok=True)
|
||||
_run(["cp", "/usr/lib/grub/x86_64-efi/monolithic/grubx64.efi", str(efi_dir / "BOOTX64.EFI")])
|
||||
|
||||
# GRUB config
|
||||
grub_cfg = iso_dir / "boot" / "grub" / "grub.cfg"
|
||||
grub_cfg.parent.mkdir(parents=True, exist_ok=True)
|
||||
grub_cfg.write_text(f"""
|
||||
set timeout=10
|
||||
set default=0
|
||||
|
||||
menuentry "{name}" {{
|
||||
linux /boot/x86_64/vmlinuz boot=live components
|
||||
initrd /boot/x86_64/initrd
|
||||
}}
|
||||
|
||||
menuentry "{name} (copy to RAM)" {{
|
||||
linux /boot/x86_64/vmlinuz boot=live components toram
|
||||
initrd /boot/x86_64/initrd
|
||||
}}
|
||||
""")
|
||||
|
||||
# xorriso to create ISO
|
||||
iso_path = output / f"{name.lower().replace(' ', '-')}-{version}-x86_64.iso"
|
||||
print(f"\033[1;36m==>\033[0m \033[1mCreating ISO: {iso_path.name}\033[0m")
|
||||
_run([
|
||||
"xorriso", "-as", "mkisofs",
|
||||
"-V", label,
|
||||
"-o", str(iso_path),
|
||||
"-J", "-joliet-long",
|
||||
"-b", "boot/x86_64/filesystem.squashfs",
|
||||
"-no-emul-boot", "-boot-load-size", "4",
|
||||
"-boot-info-table",
|
||||
"-eltorito-alt-boot",
|
||||
"-e", "boot/x86_64/filesystem.squashfs",
|
||||
"-no-emul-boot",
|
||||
str(iso_dir),
|
||||
])
|
||||
|
||||
size_mb = iso_path.stat().st_size / (1024 * 1024)
|
||||
print(f"\n\033[1;32m==>\033[0m \033[1mISO ready: {iso_path} ({size_mb:.1f} MB)\033[0m")
|
||||
return iso_path
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
||||
print(f" \033[90m$\033[0m \033[1m{' '.join(cmd)}\033[0m")
|
||||
return subprocess.run(cmd, check=True, **kw)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Rootfs builder — creates the root filesystem."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from .distros import get as get_distro
|
||||
|
||||
|
||||
def build(config: dict, output: Path) -> Path:
|
||||
"""Build a rootfs and return its path."""
|
||||
distro_name = config["distro"]
|
||||
distro_cls = get_distro(distro_name)
|
||||
|
||||
# artix vs arch
|
||||
if distro_name in ("artix",):
|
||||
distro = distro_cls(family="artix")
|
||||
elif distro_name in ("debian", "ubuntu"):
|
||||
distro = distro_cls(family=distro_name)
|
||||
elif distro_name in ("fedora", "rocky", "almalinux"):
|
||||
distro = distro_cls(family=distro_name)
|
||||
else:
|
||||
distro = distro_cls()
|
||||
|
||||
rootfs = Path(tempfile.mkdtemp(prefix="mklive-"))
|
||||
print(f"\n\033[1;36m==>\033[0m \033[1mBuilding rootfs in {rootfs}\033[0m")
|
||||
|
||||
# 1. bootstrap
|
||||
print(f"\033[1;36m==>\033[0m \033[1m[1/4] Bootstrapping {distro_name}...\033[0m")
|
||||
distro.bootstrap(rootfs, config)
|
||||
|
||||
# 2. install packages
|
||||
pkgs = config.get("packages", [])
|
||||
if pkgs:
|
||||
print(f"\033[1;36m==>\033[0m \033[1m[2/4] Installing {len(pkgs)} packages...\033[0m")
|
||||
distro.install_packages(rootfs, pkgs)
|
||||
else:
|
||||
print(f"\033[1;36m==>\033[0m \033[1m[2/4] No extra packages to install\033[0m")
|
||||
|
||||
# 3. configure
|
||||
print(f"\033[1;36m==>\033[0m \033[1m[3/4] Configuring base system...\033[0m")
|
||||
distro.configure_base(rootfs, config)
|
||||
|
||||
# 4. bootloader
|
||||
print(f"\033[1;36m==>\033[0m \033[1m[4/4] Installing bootloader...\033[0m")
|
||||
distro.install_bootloader(rootfs, config)
|
||||
|
||||
return rootfs
|
||||
|
||||
|
||||
def cleanup(rootfs: Path) -> None:
|
||||
"""Remove the temporary rootfs."""
|
||||
shutil.rmtree(rootfs, ignore_errors=True)
|
||||
@@ -0,0 +1,49 @@
|
||||
# euri-mklive config — build any distro from any distro
|
||||
#
|
||||
# usage: euri-mklive build config.yaml -o ./output
|
||||
#
|
||||
# this example builds an Euri Linux (Artix+dinit) ISO
|
||||
|
||||
distro: artix # target distro: debian, ubuntu, arch, artix, void, alpine, fedora
|
||||
arch: x86_64
|
||||
init: dinit # init system (for arch/artix/void/alpine)
|
||||
suite: # optional: release version (debian bookworm, fedora 40, etc.)
|
||||
mirror: # optional: custom mirror URL
|
||||
|
||||
hostname: euri
|
||||
|
||||
iso:
|
||||
name: Euri Linux
|
||||
version: "1.0"
|
||||
label: EURI
|
||||
|
||||
users:
|
||||
- name: euri
|
||||
shell: /bin/bash
|
||||
groups: [wheel]
|
||||
# password: set at install time, not in config
|
||||
|
||||
packages:
|
||||
# base
|
||||
- linux-firmware
|
||||
- nano
|
||||
- git
|
||||
- sudo
|
||||
- base-devel
|
||||
|
||||
# network
|
||||
- connman-dinit
|
||||
- wpa_supplicant
|
||||
|
||||
# desktop
|
||||
- plasma-desktop
|
||||
- sddm-dinit
|
||||
- konsole
|
||||
- dolphin
|
||||
|
||||
services:
|
||||
- connmand
|
||||
- sddm
|
||||
|
||||
# boot: grub (default) or systemd-boot
|
||||
bootloader: grub
|
||||
@@ -0,0 +1,59 @@
|
||||
# euri-mklive — Project Horizon
|
||||
#
|
||||
# builds the independent Euri Linux ISO
|
||||
# requires: horizon-packages repo built with xbps-src
|
||||
#
|
||||
# usage:
|
||||
# 1. build packages: cd xbps-src && ./xbps-src -A x86_64-musl pkg base-system
|
||||
# 2. build iso: euri-mklive build horizon.yaml -o ./output
|
||||
|
||||
distro: void
|
||||
arch: x86_64
|
||||
libc: musl
|
||||
init: dinit
|
||||
|
||||
hostname: euri
|
||||
|
||||
# point to our own repo, not void's
|
||||
mirror: file:///path/to/horizon-packages/x86_64-musl
|
||||
|
||||
iso:
|
||||
name: Euri Linux
|
||||
version: "0.1.0"
|
||||
label: EURI
|
||||
|
||||
users:
|
||||
- name: euri
|
||||
shell: /bin/bash
|
||||
groups: [wheel]
|
||||
|
||||
base_packages:
|
||||
- base-system
|
||||
- dinit
|
||||
|
||||
packages:
|
||||
# kernel
|
||||
- linux
|
||||
- linux-firmware
|
||||
|
||||
# network
|
||||
- connman-dinit
|
||||
- wpa_supplicant
|
||||
|
||||
# build tools
|
||||
- base-devel
|
||||
- git
|
||||
- sudo
|
||||
- nano
|
||||
|
||||
# desktop (optional — uncomment for graphical ISO)
|
||||
# - plasma-desktop
|
||||
# - sddm-dinit
|
||||
# - konsole
|
||||
# - dolphin
|
||||
|
||||
services:
|
||||
- connmand
|
||||
# - sddm
|
||||
|
||||
bootloader: grub
|
||||
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.backends._legacy:_Backend"
|
||||
|
||||
[project]
|
||||
name = "euri-mklive"
|
||||
version = "0.1.0"
|
||||
description = "Universal CLI ISO builder — any distro, from any distro"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["pyyaml>=6", "click>=8"]
|
||||
|
||||
[project.scripts]
|
||||
euri-mklive = "euri_mklive.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["euri_mklive*"]
|
||||
Reference in New Issue
Block a user