1760 lines
58 KiB
Python
1760 lines
58 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""Idempotent Fedora workstation setup — development tools, editors, and multimedia.
|
|
|
|
No external dependencies — stdlib and system tools only. Every operation
|
|
checks current state before acting; running the script repeatedly produces
|
|
the same result with no errors and no duplicate work.
|
|
|
|
Supply-chain posture:
|
|
- Bun: pinned release, ZIP downloaded from GitHub and verified against the
|
|
release's published SHA-256 (SHASUMS.txt) before installation.
|
|
- uv: installed from the official Fedora repositories (dnf).
|
|
- Zed: no checksums/signatures are published for install.sh — the version is
|
|
pinned and an explicit trust-on-first-use warning is printed.
|
|
- Brave: the repo signing key is fingerprint-verified against the official
|
|
fingerprints from https://brave.com/signing-keys/ before `rpm --import`.
|
|
|
|
Usage:
|
|
workstation-setup.py # full setup
|
|
workstation-setup.py --dry-run # preview without changes
|
|
workstation-setup.py --skip-update # skip system update
|
|
workstation-setup.py --skip-rpm # skip RPM package installation
|
|
workstation-setup.py --skip-flatpak # skip Flatpak apps
|
|
workstation-setup.py --skip-ssh # skip SSH and Git setup
|
|
workstation-setup.py --skip-repos # skip adding third-party repos
|
|
workstation-setup.py --skip-remove # skip removing pre-installed apps
|
|
workstation-setup.py --skip-bun # skip Bun installation
|
|
workstation-setup.py --skip-zed # skip Zed editor installation
|
|
workstation-setup.py --skip-uv # skip uv installation
|
|
workstation-setup.py --skip-rust # skip Rust toolchain
|
|
workstation-setup.py --skip-firewall # skip firewall setup
|
|
workstation-setup.py --skip-selinux # skip SELinux setup
|
|
workstation-setup.py --version
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import getpass
|
|
import hashlib
|
|
import os
|
|
import platform
|
|
import pwd
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import zipfile
|
|
from typing import Any
|
|
|
|
BOLD = "\033[1m"
|
|
RED = "\033[31m"
|
|
GREEN = "\033[32m"
|
|
YELLOW = "\033[33m"
|
|
DIM = "\033[2m"
|
|
RESET = "\033[0m"
|
|
VERSION = "4.1.1"
|
|
|
|
# dnf transactions (fresh installs download hundreds of packages) need far
|
|
# more than a generic 60-second timeout.
|
|
DNF_TIMEOUT = 1800
|
|
DNF_UPDATE_TIMEOUT = 3600
|
|
|
|
# Pinned versions for the curl-based installers (bump deliberately).
|
|
BUN_VERSION = "1.3.14"
|
|
ZED_VERSION = "0.216.0"
|
|
|
|
BRAVE_REPO_URL = "https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo"
|
|
BRAVE_KEY_URL = "https://brave-browser-rpm-release.s3.brave.com/brave-core.asc"
|
|
BRAVE_REPO_FILE = "/etc/yum.repos.d/brave-browser.repo"
|
|
|
|
# Official Brave "Linux Package Repositories - Release Channel" fingerprints,
|
|
# from https://brave.com/signing-keys/ (2023 primary key plus the 2025
|
|
# rotations). If Brave rotates again, verification fails loudly — update this
|
|
# set from the same page rather than weakening the check.
|
|
BRAVE_KEY_FINGERPRINTS: frozenset[str] = frozenset(
|
|
{
|
|
"DBF1A116C220B8C7164F98230686B78420388257", # Brave Linux Release (2023)
|
|
"47D32A74E9A9E013A4B4926C68D513D36A73CD96", # Brave Linux Release (2025, _mrk)
|
|
"B2A3DCA350E67256740DF904DE4EC67BE4B0DCA0", # Brave Linux Release (2025, _v2)
|
|
}
|
|
)
|
|
|
|
BUN_BASHRC_LINES: tuple[str, ...] = (
|
|
'export BUN_INSTALL="$HOME/.bun"',
|
|
'export PATH="$BUN_INSTALL/bin:$PATH"',
|
|
)
|
|
|
|
# Map os-release ID to display name.
|
|
SUPPORTED_OS: dict[str, str] = {
|
|
"fedora": "Fedora",
|
|
"openeuler": "openEuler",
|
|
}
|
|
|
|
# Pre-installed packages to remove (skipped if already absent).
|
|
REMOVE_PACKAGES: list[str] = [
|
|
"firefox",
|
|
"gnome-system-monitor",
|
|
"yelp",
|
|
"mediawriter",
|
|
]
|
|
|
|
# RPM packages to install.
|
|
RPM_PACKAGES: list[str] = [
|
|
"brave-browser",
|
|
"git",
|
|
"golang",
|
|
"rustup",
|
|
"just",
|
|
"mediainfo",
|
|
"ruby",
|
|
]
|
|
|
|
# Flatpak applications to install.
|
|
FLATPAK_APPS: list[str] = [
|
|
"org.remmina.Remmina",
|
|
"org.telegram.desktop",
|
|
"fr.handbrake.ghb",
|
|
"com.rustdesk.RustDesk",
|
|
"org.gimp.GIMP",
|
|
"net.nokyan.Resources",
|
|
"app.drey.EarTag",
|
|
"org.bunkus.mkvtoolnix-gui",
|
|
"org.gnome.Firmware",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Progress helpers — write to stderr so stdout stays clean for machine output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _status(msg: str) -> None:
|
|
"""Print a progress message to stderr — stays on the same line until ``_status_done``."""
|
|
print(f" {msg}...", end="", flush=True, file=sys.stderr)
|
|
|
|
|
|
def _status_done(msg: str = "done") -> None:
|
|
"""Complete the current progress line on stderr."""
|
|
print(f" {msg}", file=sys.stderr)
|
|
|
|
|
|
def _info(msg: str) -> None:
|
|
"""Print an informational line to stderr."""
|
|
print(f" {DIM}{msg}{RESET}", file=sys.stderr)
|
|
|
|
|
|
def _warn(msg: str) -> None:
|
|
"""Print a warning to stderr."""
|
|
print(f" {YELLOW}⚠ {msg}{RESET}", file=sys.stderr)
|
|
|
|
|
|
def _ok(msg: str) -> None:
|
|
"""Print a success message to stderr."""
|
|
print(f" {GREEN}✓ {msg}{RESET}", file=sys.stderr)
|
|
|
|
|
|
def _fail(msg: str) -> None:
|
|
"""Print a failure message to stderr."""
|
|
print(f" {RED}✗ {msg}{RESET}", file=sys.stderr)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Low-level command runners
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def run(
|
|
cmd: list[str],
|
|
timeout: int = 60,
|
|
check: bool = False,
|
|
use_sudo: bool = False,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
"""Execute a command and return the CompletedProcess.
|
|
|
|
Sets LC_ALL=C for reliable English-language output parsing and replaces
|
|
undecodable bytes in the output. When *use_sudo* is True, ``sudo`` is
|
|
prepended. Timeout and missing-binary conditions are converted into a
|
|
failed CompletedProcess (124/127) instead of raising. Raises
|
|
subprocess.CalledProcessError when *check* is True and the exit code is
|
|
non-zero.
|
|
"""
|
|
merged_env = {**os.environ, "LANG": "C", "LC_ALL": "C"}
|
|
full_cmd = ["sudo"] + cmd if use_sudo else cmd
|
|
try:
|
|
result = subprocess.run(
|
|
full_cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
errors="replace",
|
|
timeout=timeout,
|
|
env=merged_env,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
result = subprocess.CompletedProcess(full_cmd, 124, "", f"timed out after {timeout}s")
|
|
except FileNotFoundError:
|
|
result = subprocess.CompletedProcess(full_cmd, 127, "", f"command not found: {full_cmd[0]}")
|
|
except OSError as exc:
|
|
result = subprocess.CompletedProcess(full_cmd, 126, "", str(exc))
|
|
if check and result.returncode != 0:
|
|
raise subprocess.CalledProcessError(
|
|
result.returncode, full_cmd, result.stdout, result.stderr
|
|
)
|
|
return result
|
|
|
|
|
|
def _run_as_user(
|
|
cmd: list[str],
|
|
timeout: int = 60,
|
|
check: bool = False,
|
|
env: dict[str, str] | None = None,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
"""Run a command as the original user (not root) when running via sudo.
|
|
|
|
Extra environment variables are injected via env(1) so they survive
|
|
sudo's environment reset.
|
|
"""
|
|
sudo_user = os.environ.get("SUDO_USER")
|
|
if sudo_user:
|
|
full_cmd = ["sudo", "-u", sudo_user]
|
|
if env:
|
|
full_cmd.append("env")
|
|
full_cmd.extend(f"{key}={value}" for key, value in env.items())
|
|
return run(full_cmd + cmd, timeout=timeout, check=check)
|
|
return run(cmd, timeout=timeout, check=check)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OS detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def parse_os_release() -> dict[str, str]:
|
|
"""Parse /etc/os-release into a dict of KEY=VALUE pairs."""
|
|
result: dict[str, str] = {}
|
|
try:
|
|
with open("/etc/os-release", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
# Strip surrounding quotes if present.
|
|
value = value.strip().strip('"').strip("'")
|
|
result[key] = value
|
|
except OSError:
|
|
pass
|
|
return result
|
|
|
|
|
|
def detect_os() -> tuple[str, str, str]:
|
|
"""Detect the operating system.
|
|
|
|
Returns a tuple of (os_id, display_name, version_id).
|
|
Exits with an error message when the OS is not supported.
|
|
"""
|
|
release = parse_os_release()
|
|
os_id = release.get("ID", "").lower()
|
|
version_id = release.get("VERSION_ID", "unknown")
|
|
|
|
if os_id not in SUPPORTED_OS:
|
|
print(
|
|
f"{RED}{BOLD}Error:{RESET} Unsupported operating system: "
|
|
f"'{release.get('ID', 'unknown')}' (detected from /etc/os-release).",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
f" Supported systems: {', '.join(SUPPORTED_OS.values())}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
return os_id, SUPPORTED_OS[os_id], version_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Section helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _rpm_installed(pkg: str) -> bool:
|
|
"""Return True if the RPM package is already installed."""
|
|
result = run(["rpm", "-q", pkg])
|
|
return result.returncode == 0
|
|
|
|
|
|
def _have_flatpak(app: str) -> bool:
|
|
"""Return True if the Flatpak application is already installed (system-wide)."""
|
|
result = run(["flatpak", "info", app], check=False)
|
|
return result.returncode == 0
|
|
|
|
|
|
def _real_user() -> pwd.struct_passwd | None:
|
|
"""Return the passwd entry of the real (non-root) user, or None.
|
|
|
|
None means "no separate user to drop to" — e.g. the script is running in
|
|
a real root shell, or SUDO_USER does not resolve.
|
|
"""
|
|
sudo_user = os.environ.get("SUDO_USER")
|
|
if sudo_user:
|
|
try:
|
|
return pwd.getpwnam(sudo_user)
|
|
except KeyError:
|
|
return None
|
|
if os.geteuid() == 0:
|
|
return None
|
|
try:
|
|
return pwd.getpwuid(os.geteuid())
|
|
except KeyError:
|
|
return None
|
|
|
|
|
|
def _real_home() -> str:
|
|
"""Return the real user's home directory, even when running via sudo."""
|
|
real = _real_user()
|
|
if real is not None:
|
|
return real.pw_dir
|
|
return os.path.expanduser("~")
|
|
|
|
|
|
def _chown_user(path: str) -> None:
|
|
"""Chown *path* to the real user; no-op when there is no separate user."""
|
|
real = _real_user()
|
|
if real is not None:
|
|
os.chown(path, real.pw_uid, real.pw_gid)
|
|
|
|
|
|
def _ensure_user_owns(path: str) -> bool:
|
|
"""Verify *path* is owned by the real user, fixing root ownership.
|
|
|
|
Returns True when ownership is correct (or there is no separate user).
|
|
"""
|
|
real = _real_user()
|
|
if real is None:
|
|
return True
|
|
try:
|
|
if os.stat(path).st_uid == real.pw_uid:
|
|
return True
|
|
os.chown(path, real.pw_uid, real.pw_gid)
|
|
return os.stat(path).st_uid == real.pw_uid
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _tool_path(name: str, *home_rel: str) -> str | None:
|
|
"""Absolute path to *name*: PATH first, then user-home fallback locations."""
|
|
found = shutil.which(name)
|
|
if found is not None:
|
|
return found
|
|
home = _real_home()
|
|
for rel in home_rel:
|
|
candidate = os.path.join(home, rel)
|
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _probe_version(cmd: list[str], as_user: bool = True) -> str:
|
|
"""Run a --version probe and return the first line of output, or 'unknown'."""
|
|
runner = _run_as_user if as_user else run
|
|
result = runner(cmd)
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
return "unknown"
|
|
return result.stdout.strip().split("\n")[0]
|
|
|
|
|
|
def _download(url: str, dest: str, timeout: int = 300) -> bool:
|
|
"""Download *url* to *dest* with curl; return True on success."""
|
|
result = run(
|
|
["curl", "-fsSL", "-o", dest, url],
|
|
timeout=timeout,
|
|
use_sudo=True,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
|
|
def _sha256_file(path: str) -> str:
|
|
"""Return the hex SHA-256 digest of a file, read in chunks."""
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _sha256_from_sums(sums_file: str, filename: str) -> str | None:
|
|
"""Extract the SHA-256 for *filename* from a sha256sum-format file."""
|
|
try:
|
|
with open(sums_file, encoding="utf-8") as fh:
|
|
for line in fh:
|
|
parts = line.split()
|
|
if len(parts) == 2 and parts[1].lstrip("*") == filename:
|
|
return parts[0].lower()
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _asc_fingerprints(key_file: str) -> set[str]:
|
|
"""Fingerprints of every key in an ASCII-armoured key file (via gpg)."""
|
|
if shutil.which("gpg") is None:
|
|
return set()
|
|
result = run(["gpg", "--show-keys", "--with-colons", key_file])
|
|
if result.returncode != 0:
|
|
return set()
|
|
fingerprints: set[str] = set()
|
|
for line in result.stdout.splitlines():
|
|
parts = line.split(":")
|
|
if parts[0] == "fpr" and len(parts) > 9 and parts[9]:
|
|
fingerprints.add(parts[9].upper())
|
|
return fingerprints
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. System update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def system_update(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Update the system via dnf.
|
|
|
|
Returns a dict with summary info: {'updated', 'skipped', 'would_update',
|
|
'error'}.
|
|
"""
|
|
info: dict[str, Any] = {
|
|
"updated": False,
|
|
"skipped": False,
|
|
"would_update": False,
|
|
"error": False,
|
|
}
|
|
|
|
_status("Checking for system updates")
|
|
check_result = run(["dnf", "check-update"], timeout=300, use_sudo=True)
|
|
# dnf check-update: 0 = no updates, 100 = updates available, else = error.
|
|
if check_result.returncode == 0:
|
|
_status_done("already up to date")
|
|
info["skipped"] = True
|
|
return info
|
|
if check_result.returncode != 100:
|
|
_status_done("check failed")
|
|
_fail(f"dnf check-update failed: {check_result.stderr.strip() or 'unknown error'}")
|
|
info["error"] = True
|
|
return info
|
|
|
|
_status_done("updates available")
|
|
|
|
if dry_run:
|
|
_info("Would run: dnf update -y")
|
|
info["would_update"] = True
|
|
return info
|
|
|
|
_status("Applying system updates")
|
|
update_result = run(["dnf", "update", "-y"], timeout=DNF_UPDATE_TIMEOUT, use_sudo=True)
|
|
if update_result.returncode == 0:
|
|
_status_done()
|
|
info["updated"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail("System update returned non-zero exit code")
|
|
info["error"] = True
|
|
return info
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Remove pre-installed apps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def remove_packages(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Remove unwanted pre-installed packages.
|
|
|
|
Returns a dict with 'removed' (list) and 'skipped' (list).
|
|
"""
|
|
removed: list[str] = []
|
|
skipped: list[str] = []
|
|
|
|
for pkg in REMOVE_PACKAGES:
|
|
_status(f"Checking {pkg}")
|
|
if not _rpm_installed(pkg):
|
|
_status_done("not installed, skipping")
|
|
skipped.append(pkg)
|
|
continue
|
|
|
|
if dry_run:
|
|
_status_done("would remove")
|
|
removed.append(pkg)
|
|
else:
|
|
result = run(["dnf", "remove", "-y", pkg], timeout=DNF_TIMEOUT, use_sudo=True)
|
|
if result.returncode == 0:
|
|
_status_done("removed")
|
|
removed.append(pkg)
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"Failed to remove {pkg}")
|
|
|
|
return {"removed": removed, "skipped": skipped}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Third-party RPM repos
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _import_brave_key() -> bool:
|
|
"""Download, fingerprint-verify, and import Brave's RPM signing key."""
|
|
if shutil.which("gpg") is None:
|
|
_fail("gpg not found — cannot verify the Brave signing key")
|
|
return False
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
key_file = os.path.join(tmp, "brave-core.asc")
|
|
if not _download(BRAVE_KEY_URL, key_file, timeout=60):
|
|
_fail("Failed to download the Brave signing key")
|
|
return False
|
|
fingerprints = _asc_fingerprints(key_file)
|
|
if not fingerprints:
|
|
_fail("No keys found in the downloaded Brave key file")
|
|
return False
|
|
if not fingerprints.issubset(BRAVE_KEY_FINGERPRINTS):
|
|
_fail(
|
|
"Brave signing key fingerprint mismatch — expected one of: "
|
|
+ ", ".join(sorted(BRAVE_KEY_FINGERPRINTS))
|
|
+ "; got: "
|
|
+ ", ".join(sorted(fingerprints))
|
|
)
|
|
return False
|
|
_ok("Brave signing key fingerprint verified")
|
|
result = run(["rpm", "--import", key_file], use_sudo=True)
|
|
if result.returncode != 0:
|
|
_fail("rpm --import failed for the Brave signing key")
|
|
return False
|
|
return True
|
|
|
|
|
|
def _add_brave_repo() -> bool:
|
|
"""Add the Brave repo using the correct config-manager syntax (dnf5/dnf4)."""
|
|
if shutil.which("dnf5") is not None:
|
|
cmd = ["dnf", "config-manager", "addrepo", f"--from-repofile={BRAVE_REPO_URL}"]
|
|
else:
|
|
# dnf4 (Fedora <= 40, openEuler) has no 'addrepo' subcommand.
|
|
cmd = ["dnf", "config-manager", "--add-repo", BRAVE_REPO_URL]
|
|
result = run(cmd, timeout=120, use_sudo=True)
|
|
if result.returncode != 0:
|
|
_fail(
|
|
"Failed to add the Brave repo (on dnf4 systems this requires "
|
|
"the dnf-plugins-core package)"
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def setup_repos(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Add third-party RPM repositories.
|
|
|
|
Returns a dict with 'added' (list) and 'skipped' (list).
|
|
"""
|
|
added: list[str] = []
|
|
skipped: list[str] = []
|
|
|
|
# --- Brave Browser ---
|
|
repo_name = "brave-browser"
|
|
_status(f"Checking {repo_name} repo")
|
|
if os.path.exists(BRAVE_REPO_FILE):
|
|
_status_done("already present")
|
|
skipped.append(repo_name)
|
|
elif dry_run:
|
|
_status_done("would verify + import GPG key, would add repo")
|
|
added.append(repo_name)
|
|
else:
|
|
if _import_brave_key() and _add_brave_repo():
|
|
_status_done("added")
|
|
added.append(repo_name)
|
|
else:
|
|
_status_done("failed")
|
|
|
|
return {"added": added, "skipped": skipped}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. RPM packages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def install_rpm_packages(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Install RPM packages, skipping any that are already present.
|
|
|
|
Installs in a single dnf transaction, falling back to per-package
|
|
installs if the batch fails. Returns a dict with 'installed' (list),
|
|
'skipped' (list), 'failed' (list).
|
|
"""
|
|
installed: list[str] = []
|
|
skipped: list[str] = []
|
|
failed: list[str] = []
|
|
to_install: list[str] = []
|
|
|
|
_status("Checking RPM packages")
|
|
for pkg in RPM_PACKAGES:
|
|
if _rpm_installed(pkg):
|
|
skipped.append(pkg)
|
|
else:
|
|
to_install.append(pkg)
|
|
|
|
already = len(skipped)
|
|
missing = len(to_install)
|
|
total = len(RPM_PACKAGES)
|
|
_status_done(f"{already}/{total} already installed")
|
|
|
|
if not to_install:
|
|
_ok("All RPM packages already present")
|
|
return {"installed": installed, "skipped": skipped, "failed": failed}
|
|
|
|
_info(f"Installing {missing} package(s): {' '.join(to_install)}")
|
|
|
|
if dry_run:
|
|
for pkg in to_install:
|
|
_info(f" Would install: {pkg}")
|
|
installed.append(pkg)
|
|
return {"installed": installed, "skipped": skipped, "failed": failed}
|
|
|
|
_status(f"Installing {missing} package(s)")
|
|
batch = run(
|
|
["dnf", "install", "-y", *to_install],
|
|
timeout=DNF_TIMEOUT,
|
|
use_sudo=True,
|
|
)
|
|
if batch.returncode == 0:
|
|
_status_done()
|
|
installed.extend(to_install)
|
|
return {"installed": installed, "skipped": skipped, "failed": failed}
|
|
|
|
_status_done("batch failed, retrying one by one")
|
|
for pkg in to_install:
|
|
_status(f"Installing {pkg}")
|
|
result = run(["dnf", "install", "-y", pkg], timeout=DNF_TIMEOUT, use_sudo=True)
|
|
if result.returncode == 0:
|
|
_status_done()
|
|
installed.append(pkg)
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"Failed to install {pkg}")
|
|
failed.append(pkg)
|
|
|
|
return {"installed": installed, "skipped": skipped, "failed": failed}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Dev tool installers (Bun, Zed, uv)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _add_bashrc_lines(lines: tuple[str, ...], comment: str) -> bool:
|
|
"""Append export lines to ~/.bashrc unless each is already present.
|
|
|
|
Matches on the export line content, not on the surrounding block, so a
|
|
block previously written by an upstream installer is recognised too.
|
|
Returns True when a block was appended.
|
|
"""
|
|
home = _real_home()
|
|
bashrc = os.path.join(home, ".bashrc")
|
|
if not os.path.exists(bashrc):
|
|
return False
|
|
try:
|
|
with open(bashrc, encoding="utf-8") as fh:
|
|
content = fh.read()
|
|
except OSError:
|
|
return False
|
|
if all(line in content for line in lines):
|
|
return False
|
|
try:
|
|
with open(bashrc, "a", encoding="utf-8") as fh:
|
|
fh.write(f"\n# {comment}\n" + "\n".join(lines) + "\n")
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _bun_target() -> str:
|
|
"""Bun release artifact name for this machine (mirrors the install script)."""
|
|
machine = platform.machine()
|
|
if machine in ("aarch64", "arm64"):
|
|
return "bun-linux-aarch64"
|
|
target = "bun-linux-x64"
|
|
try:
|
|
with open("/proc/cpuinfo", encoding="utf-8") as fh:
|
|
if "avx2" not in fh.read():
|
|
target = "bun-linux-x64-baseline"
|
|
except OSError:
|
|
pass
|
|
return target
|
|
|
|
|
|
def _install_bun_verified() -> str | None:
|
|
"""Download pinned Bun, verify its SHA-256, install to ~/.bun/bin.
|
|
|
|
Returns the installed binary path on success, None on failure.
|
|
"""
|
|
target = _bun_target()
|
|
zip_name = f"{target}.zip"
|
|
base_url = f"https://github.com/oven-sh/bun/releases/download/bun-v{BUN_VERSION}"
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
zip_path = os.path.join(tmp, zip_name)
|
|
sums_path = os.path.join(tmp, "SHASUMS.txt")
|
|
if not _download(f"{base_url}/{zip_name}", zip_path):
|
|
_fail(f"Failed to download {zip_name} (bun v{BUN_VERSION})")
|
|
return None
|
|
if not _download(f"{base_url}/SHASUMS.txt", sums_path, timeout=60):
|
|
_fail("Failed to download Bun SHASUMS.txt")
|
|
return None
|
|
|
|
expected = _sha256_from_sums(sums_path, zip_name)
|
|
if expected is None:
|
|
_fail(f"No checksum for {zip_name} in SHASUMS.txt")
|
|
return None
|
|
actual = _sha256_file(zip_path)
|
|
if actual != expected:
|
|
_fail(f"SHA-256 mismatch for {zip_name}: expected {expected}, got {actual}")
|
|
return None
|
|
_info("SHA-256 checksum verified")
|
|
|
|
home = _real_home()
|
|
bin_dir = os.path.join(home, ".bun", "bin")
|
|
made = _run_as_user(["install", "-d", "-m", "755", bin_dir])
|
|
if made.returncode != 0:
|
|
_fail(f"Failed to create {bin_dir}")
|
|
return None
|
|
|
|
try:
|
|
with zipfile.ZipFile(zip_path) as archive:
|
|
data = archive.read(f"{target}/bun")
|
|
except (OSError, KeyError, zipfile.BadZipFile) as exc:
|
|
_fail(f"Failed to extract bun from {zip_name}: {exc}")
|
|
return None
|
|
|
|
bun_path = os.path.join(bin_dir, "bun")
|
|
try:
|
|
with open(bun_path, "wb") as fh:
|
|
fh.write(data)
|
|
os.chmod(bun_path, 0o755)
|
|
_chown_user(bun_path)
|
|
except OSError as exc:
|
|
_fail(f"Failed to install bun to {bun_path}: {exc}")
|
|
return None
|
|
return bun_path
|
|
|
|
|
|
def setup_bun(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Install Bun from the pinned, checksum-verified GitHub release.
|
|
|
|
Returns a dict with 'installed' (bool) and 'version' (str).
|
|
"""
|
|
info: dict[str, Any] = {"installed": False, "version": "", "planned": False}
|
|
|
|
_status("Checking Bun")
|
|
bun = _tool_path("bun", ".bun/bin/bun")
|
|
if bun is not None:
|
|
info["version"] = _probe_version([bun, "--version"])
|
|
_status_done(info["version"])
|
|
info["installed"] = True
|
|
return info
|
|
|
|
_status_done("not installed")
|
|
|
|
if dry_run:
|
|
_info(
|
|
f"Would download bun v{BUN_VERSION} + SHASUMS.txt from GitHub, "
|
|
"verify SHA-256, and install to ~/.bun/bin"
|
|
)
|
|
info["planned"] = True
|
|
return info
|
|
|
|
_status(f"Installing Bun v{BUN_VERSION}")
|
|
bun_path = _install_bun_verified()
|
|
if bun_path is None:
|
|
_status_done("failed")
|
|
return info
|
|
_status_done()
|
|
info["installed"] = True
|
|
if _add_bashrc_lines(BUN_BASHRC_LINES, "bun"):
|
|
_info("Added Bun PATH to ~/.bashrc")
|
|
info["version"] = _probe_version([bun_path, "--version"])
|
|
return info
|
|
|
|
|
|
def setup_zed(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Install Zed via the official install script with a pinned version.
|
|
|
|
Zed publishes no checksums or signatures for install.sh; the download is
|
|
trust-on-first-use over HTTPS and a warning is printed accordingly.
|
|
Returns a dict with 'installed' (bool) and 'version' (str).
|
|
"""
|
|
info: dict[str, Any] = {"installed": False, "version": "", "planned": False}
|
|
|
|
_status("Checking Zed")
|
|
zed = _tool_path("zed", ".local/bin/zed")
|
|
if zed is not None:
|
|
info["version"] = _probe_version([zed, "--version"])
|
|
_status_done(info["version"])
|
|
info["installed"] = True
|
|
return info
|
|
|
|
_status_done("not installed")
|
|
|
|
if dry_run:
|
|
_info(f"Would run the Zed install script with ZED_VERSION={ZED_VERSION}")
|
|
info["planned"] = True
|
|
return info
|
|
|
|
_warn(
|
|
"Zed publishes no checksums/signatures for install.sh — "
|
|
"trusting https://zed.dev over HTTPS (version pinned)"
|
|
)
|
|
_status(f"Installing Zed {ZED_VERSION}")
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
script = os.path.join(tmp, "zed-install.sh")
|
|
if not _download("https://zed.dev/install.sh", script, timeout=60):
|
|
_status_done("download failed")
|
|
_fail("Failed to download the Zed install script")
|
|
return info
|
|
# The script runs as the real user, who cannot enter root's 0700 tmpdir.
|
|
os.chmod(tmp, 0o755)
|
|
os.chmod(script, 0o644)
|
|
result = _run_as_user(
|
|
["sh", script],
|
|
timeout=300,
|
|
env={"ZED_VERSION": ZED_VERSION},
|
|
)
|
|
if result.returncode == 0:
|
|
_status_done()
|
|
info["installed"] = True
|
|
zed_path = _tool_path("zed", ".local/bin/zed")
|
|
if zed_path is not None:
|
|
info["version"] = _probe_version([zed_path, "--version"])
|
|
else:
|
|
_status_done("failed")
|
|
_fail("Zed installation returned non-zero exit code")
|
|
|
|
return info
|
|
|
|
|
|
def setup_uv(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Install uv from the official Fedora repositories.
|
|
|
|
Returns a dict with 'installed' (bool) and 'version' (str).
|
|
"""
|
|
info: dict[str, Any] = {"installed": False, "version": "", "planned": False}
|
|
|
|
_status("Checking uv")
|
|
uv = _tool_path("uv", ".local/bin/uv")
|
|
if uv is not None:
|
|
info["version"] = _probe_version([uv, "--version"])
|
|
_status_done(info["version"])
|
|
info["installed"] = True
|
|
return info
|
|
|
|
_status_done("not installed")
|
|
|
|
if dry_run:
|
|
_info("Would install uv via: dnf install -y uv (official Fedora package)")
|
|
info["planned"] = True
|
|
return info
|
|
|
|
_status("Installing uv")
|
|
result = run(["dnf", "install", "-y", "uv"], timeout=DNF_TIMEOUT, use_sudo=True)
|
|
if result.returncode == 0:
|
|
_status_done()
|
|
info["installed"] = True
|
|
uv_path = _tool_path("uv", ".local/bin/uv")
|
|
if uv_path is not None:
|
|
info["version"] = _probe_version([uv_path, "--version"], as_user=False)
|
|
else:
|
|
_status_done("failed")
|
|
_fail("uv installation via dnf returned non-zero exit code")
|
|
|
|
return info
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Rust toolchain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def setup_rust(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Install the Rust toolchain via rustup-init if not present.
|
|
|
|
Returns a dict with 'installed' (bool) and 'version' (str).
|
|
"""
|
|
info: dict[str, Any] = {"installed": False, "version": "", "planned": False}
|
|
|
|
_status("Checking Rust")
|
|
rustc = _tool_path("rustc", ".cargo/bin/rustc")
|
|
if rustc is not None:
|
|
info["version"] = _probe_version([rustc, "--version"])
|
|
_status_done(info["version"])
|
|
info["installed"] = True
|
|
return info
|
|
|
|
_status_done("not installed")
|
|
|
|
if dry_run:
|
|
_info("Would run: rustup-init -y")
|
|
info["planned"] = True
|
|
return info
|
|
|
|
rustup_init = _tool_path("rustup-init")
|
|
if rustup_init is None:
|
|
_fail("rustup-init not found — install the 'rustup' RPM first (or do not pass --skip-rpm)")
|
|
return info
|
|
|
|
_status("Installing Rust toolchain")
|
|
result = _run_as_user([rustup_init, "-y"], timeout=300)
|
|
if result.returncode == 0:
|
|
rustc_path = os.path.join(_real_home(), ".cargo/bin/rustc")
|
|
info["version"] = _probe_version([rustc_path, "--version"])
|
|
_status_done(info["version"])
|
|
info["installed"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail("Rust installation returned non-zero exit code")
|
|
|
|
return info
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Flatpak apps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _flatpak_remote_names() -> set[str]:
|
|
"""Names of configured system-wide Flatpak remotes (exact match, no substrings)."""
|
|
result = run(["flatpak", "remotes"], check=False)
|
|
names: set[str] = set()
|
|
for line in result.stdout.splitlines():
|
|
tokens = line.split()
|
|
if tokens and tokens[0] != "Name": # skip the table header
|
|
names.add(tokens[0])
|
|
return names
|
|
|
|
|
|
def setup_flatpak(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Set up Flathub remote and install Flatpak applications.
|
|
|
|
Installs are system-wide (run as root). Caveat: apps previously installed
|
|
per-user (e.g. via GNOME Software) live in the user installation and are
|
|
NOT detected here — they would be duplicated system-wide.
|
|
|
|
Returns a dict with 'remote_added' (bool), 'installed' (list),
|
|
'skipped' (list), and 'failed' (list).
|
|
"""
|
|
installed: list[str] = []
|
|
skipped: list[str] = []
|
|
failed: list[str] = []
|
|
remote_added = False
|
|
|
|
# --- Ensure Flathub remote ---
|
|
_status("Checking Flathub remote")
|
|
if "flathub" in _flatpak_remote_names():
|
|
_status_done("already present")
|
|
elif dry_run:
|
|
_status_done("would add")
|
|
remote_added = True
|
|
else:
|
|
result = run(
|
|
[
|
|
"flatpak",
|
|
"remote-add",
|
|
"--if-not-exists",
|
|
"flathub",
|
|
"https://flathub.org/repo/flathub.flatpakrepo",
|
|
],
|
|
timeout=120,
|
|
use_sudo=True,
|
|
)
|
|
if result.returncode == 0:
|
|
_status_done("added")
|
|
remote_added = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail("Failed to add Flathub remote")
|
|
|
|
# --- Install each app ---
|
|
for app in FLATPAK_APPS:
|
|
_status(f"Checking {app}")
|
|
if _have_flatpak(app):
|
|
_status_done("already installed")
|
|
skipped.append(app)
|
|
continue
|
|
|
|
if dry_run:
|
|
_status_done("would install")
|
|
installed.append(app)
|
|
else:
|
|
result = run(
|
|
["flatpak", "install", "-y", "flathub", app],
|
|
timeout=300,
|
|
use_sudo=True,
|
|
)
|
|
if result.returncode == 0:
|
|
_status_done("installed")
|
|
installed.append(app)
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"Failed to install {app}")
|
|
failed.append(app)
|
|
|
|
return {
|
|
"remote_added": remote_added,
|
|
"installed": installed,
|
|
"skipped": skipped,
|
|
"failed": failed,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. SSH + Git
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _prompt_ssh_passphrase() -> str | None:
|
|
"""Prompt for an SSH key passphrase. Returns None to abort the section.
|
|
|
|
Empty is allowed but loudly warned about; non-interactive runs get an
|
|
empty passphrase with an equally loud warning.
|
|
"""
|
|
if not sys.stdin.isatty():
|
|
_warn("Non-interactive run — the SSH key will be generated WITHOUT a passphrase")
|
|
return ""
|
|
try:
|
|
first = getpass.getpass("SSH key passphrase (empty = no passphrase): ")
|
|
second = getpass.getpass("Confirm passphrase: ")
|
|
except (EOFError, KeyboardInterrupt):
|
|
print(file=sys.stderr)
|
|
return None
|
|
if first != second:
|
|
_fail("Passphrases do not match")
|
|
return None
|
|
if not first:
|
|
_warn(
|
|
"Empty passphrase — anyone who can read the key file can use it. "
|
|
"Consider re-generating with a passphrase and using ssh-agent."
|
|
)
|
|
return first
|
|
|
|
|
|
def setup_ssh(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Set up SSH keys and Git configuration for sourcedock.dev — interactively.
|
|
|
|
Everything is created as the real user (via sudo -u / chown), never
|
|
left root-owned in the user's home. Returns a dict with 'skipped',
|
|
'key_generated', 'key_existed', 'config_updated', 'git_configured',
|
|
and 'dry_run'.
|
|
"""
|
|
info: dict[str, Any] = {
|
|
"skipped": False,
|
|
"key_generated": False,
|
|
"key_existed": False,
|
|
"config_updated": False,
|
|
"git_configured": False,
|
|
"dry_run": dry_run,
|
|
}
|
|
ssh_dir = os.path.join(_real_home(), ".ssh")
|
|
key_path = os.path.join(ssh_dir, "id_ed25519")
|
|
config_path = os.path.join(ssh_dir, "config")
|
|
pub_path = key_path + ".pub"
|
|
|
|
if dry_run:
|
|
_info("Would prompt for Git identity (name, email)")
|
|
_info("Would generate an ed25519 SSH key if missing")
|
|
_info("Would prompt for a key passphrase (empty only with a warning)")
|
|
_info("Would configure ~/.ssh/config for sourcedock.dev")
|
|
_info("Would set git user.name, user.email and the sourcedock.dev insteadOf rule")
|
|
return info
|
|
|
|
# Ask whether to set up SSH at all
|
|
try:
|
|
answer = input("Set up sourcedock.dev SSH + Git? [Y/n]: ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print(file=sys.stderr)
|
|
_info("SSH + Git: skipped")
|
|
info["skipped"] = True
|
|
return info
|
|
if answer in ("n", "no"):
|
|
_info("SSH + Git: skipped")
|
|
info["skipped"] = True
|
|
return info
|
|
|
|
# Ask for user details with defaults
|
|
try:
|
|
user_name = input("Git user name [Petr Balvín]: ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print(file=sys.stderr)
|
|
_info("SSH + Git: skipped")
|
|
info["skipped"] = True
|
|
return info
|
|
if not user_name:
|
|
user_name = "Petr Balvín"
|
|
|
|
try:
|
|
user_email = input("Git email [opensource@petrbalvin.org]: ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print(file=sys.stderr)
|
|
_info("SSH + Git: skipped")
|
|
info["skipped"] = True
|
|
return info
|
|
if not user_email:
|
|
user_email = "opensource@petrbalvin.org"
|
|
|
|
info["key_existed"] = os.path.exists(key_path)
|
|
|
|
# --- Generate ED25519 SSH key (as the real user) ---
|
|
_status("Checking SSH key")
|
|
if info["key_existed"]:
|
|
_status_done("already exists")
|
|
else:
|
|
passphrase = _prompt_ssh_passphrase()
|
|
if passphrase is None:
|
|
info["skipped"] = True
|
|
return info
|
|
made = _run_as_user(["install", "-d", "-m", "700", ssh_dir])
|
|
if made.returncode != 0:
|
|
_status_done("failed")
|
|
_fail(f"Failed to create {ssh_dir}")
|
|
return info
|
|
# NOTE: the passphrase passes through the process argv briefly
|
|
# (visible to the user and root in ps) — standard for unattended
|
|
# ssh-keygen; interactive alternative would block automation.
|
|
result = _run_as_user(
|
|
[
|
|
"ssh-keygen",
|
|
"-t",
|
|
"ed25519",
|
|
"-C",
|
|
user_email,
|
|
"-N",
|
|
passphrase,
|
|
"-f",
|
|
key_path,
|
|
],
|
|
)
|
|
if result.returncode == 0:
|
|
_status_done("generated")
|
|
info["key_generated"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail("Failed to generate SSH key")
|
|
|
|
# --- Configure ~/.ssh/config ---
|
|
host_entry = (
|
|
f"Host sourcedock.dev\n User git\n IdentityFile {key_path}\n IdentitiesOnly yes\n"
|
|
)
|
|
|
|
_status("Checking SSH config")
|
|
made = _run_as_user(["install", "-d", "-m", "700", ssh_dir])
|
|
if made.returncode != 0:
|
|
_status_done("failed")
|
|
_fail(f"Failed to create {ssh_dir}")
|
|
return info
|
|
|
|
existing = ""
|
|
if os.path.exists(config_path):
|
|
try:
|
|
with open(config_path, encoding="utf-8") as f:
|
|
existing = f.read()
|
|
except OSError:
|
|
existing = ""
|
|
|
|
if "Host sourcedock.dev" in existing:
|
|
_status_done("already configured")
|
|
else:
|
|
try:
|
|
with open(config_path, "a", encoding="utf-8") as f:
|
|
f.write("\n" + host_entry if existing else host_entry)
|
|
os.chmod(config_path, 0o600)
|
|
_chown_user(config_path)
|
|
_status_done("updated")
|
|
info["config_updated"] = True
|
|
except OSError:
|
|
_status_done("failed")
|
|
_fail("Failed to update SSH config")
|
|
|
|
# --- Verify nothing in ~/.ssh ended up root-owned ---
|
|
for path in (ssh_dir, key_path, pub_path, config_path):
|
|
if os.path.exists(path) and not _ensure_user_owns(path):
|
|
_fail(f"{path} is root-owned and could not be fixed")
|
|
|
|
# --- Git SSH configuration (as the real user) ---
|
|
git_configs = {
|
|
"user.name": user_name,
|
|
"user.email": user_email,
|
|
'url."git@sourcedock.dev:".insteadOf': "https://sourcedock.dev/",
|
|
}
|
|
|
|
for key, value in git_configs.items():
|
|
_status(f"Checking git config {key}")
|
|
result = _run_as_user(["git", "config", "--global", "--get", key])
|
|
if result.returncode == 0 and result.stdout.strip() == value:
|
|
_status_done("already set")
|
|
else:
|
|
result = _run_as_user(["git", "config", "--global", key, value])
|
|
if result.returncode == 0:
|
|
_status_done("set")
|
|
info["git_configured"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"Failed to set git config {key}")
|
|
|
|
# --- Show public key ---
|
|
if info["key_generated"] or info["key_existed"]:
|
|
try:
|
|
with open(pub_path, encoding="utf-8") as f:
|
|
pub_key = f.read().strip()
|
|
print(file=sys.stderr)
|
|
print(
|
|
f"\n{BOLD} Your SSH public key"
|
|
f" (add to https://sourcedock.dev/user/settings/keys):{RESET}\n",
|
|
file=sys.stderr,
|
|
)
|
|
print(f" {GREEN}{pub_key}{RESET}", file=sys.stderr)
|
|
print(file=sys.stderr)
|
|
except OSError:
|
|
_warn("SSH public key not found")
|
|
|
|
return info
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Firewall
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def setup_firewall(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Ensure firewalld is installed, enabled, and running.
|
|
|
|
Unlike server-setup, does NOT change the default zone — the
|
|
workstation default (FedoraWorkstation) is left as-is.
|
|
|
|
Returns a dict with summary of what was configured; 'enabled' and
|
|
'running' always reflect the actual probed state.
|
|
"""
|
|
info: dict[str, Any] = {
|
|
"installed": False,
|
|
"enabled": False,
|
|
"running": False,
|
|
}
|
|
|
|
# --- Install firewalld if missing ---
|
|
_status("Checking firewalld")
|
|
if not _rpm_installed("firewalld"):
|
|
_status_done("not installed")
|
|
if dry_run:
|
|
_info("Would install: firewalld")
|
|
else:
|
|
result = run(
|
|
["dnf", "install", "-y", "firewalld"],
|
|
timeout=DNF_TIMEOUT,
|
|
use_sudo=True,
|
|
)
|
|
if result.returncode == 0:
|
|
_ok("Installed firewalld")
|
|
info["installed"] = True
|
|
else:
|
|
_fail("Failed to install firewalld")
|
|
return info
|
|
else:
|
|
_status_done("installed")
|
|
info["installed"] = True
|
|
|
|
# --- Enable and start ---
|
|
_status("Enabling firewalld service")
|
|
enabled = run(["systemctl", "is-enabled", "firewalld.service"], check=False)
|
|
if enabled.returncode != 0:
|
|
if dry_run:
|
|
_status_done("would enable")
|
|
else:
|
|
result = run(["systemctl", "enable", "firewalld.service"], use_sudo=True)
|
|
if result.returncode == 0:
|
|
_status_done("enabled")
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"Failed to enable firewalld: {result.stderr.strip()}")
|
|
else:
|
|
_status_done("already enabled")
|
|
|
|
_status("Starting firewalld service")
|
|
active = run(["systemctl", "is-active", "firewalld.service"], check=False)
|
|
if active.returncode != 0:
|
|
if dry_run:
|
|
_status_done("would start")
|
|
else:
|
|
result = run(["systemctl", "start", "firewalld.service"], use_sudo=True)
|
|
if result.returncode == 0:
|
|
_status_done("started")
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"Failed to start firewalld: {result.stderr.strip()}")
|
|
else:
|
|
_status_done("already running")
|
|
|
|
# Reflect the real probed state, never assumed success.
|
|
if not dry_run:
|
|
info["enabled"] = (
|
|
run(["systemctl", "is-enabled", "firewalld.service"], check=False).returncode == 0
|
|
)
|
|
info["running"] = (
|
|
run(["systemctl", "is-active", "firewalld.service"], check=False).returncode == 0
|
|
)
|
|
|
|
return info
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. SELinux
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def setup_selinux(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Ensure SELinux is enforcing.
|
|
|
|
A Disabled SELinux cannot be switched at runtime — only the config file
|
|
is fixed and a reboot warning is printed (setenforce always fails there).
|
|
|
|
Returns a dict with summary of changes.
|
|
"""
|
|
info: dict[str, Any] = {
|
|
"mode_changed": False,
|
|
"config_changed": False,
|
|
"current_mode": "unknown",
|
|
"reboot_required": False,
|
|
}
|
|
|
|
# --- Check current mode ---
|
|
_status("Checking SELinux mode")
|
|
mode_result = run(["getenforce"], check=False)
|
|
current_mode = mode_result.stdout.strip()
|
|
info["current_mode"] = current_mode
|
|
_status_done(current_mode)
|
|
|
|
if current_mode == "Permissive":
|
|
if dry_run:
|
|
_info("Would set SELinux to enforcing mode")
|
|
else:
|
|
_status("Setting SELinux to enforcing")
|
|
result = run(["setenforce", "1"], use_sudo=True)
|
|
if result.returncode == 0:
|
|
_status_done()
|
|
info["mode_changed"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"setenforce 1 failed: {result.stderr.strip()}")
|
|
elif current_mode == "Disabled":
|
|
_warn(
|
|
"SELinux is disabled — it cannot be enabled at runtime. "
|
|
"Setting SELINUX=enforcing in the config; a REBOOT is required."
|
|
)
|
|
info["reboot_required"] = True
|
|
|
|
# --- Ensure SELINUX=enforcing in config ---
|
|
_status("Checking /etc/selinux/config")
|
|
try:
|
|
with open("/etc/selinux/config", encoding="utf-8") as fh:
|
|
lines = fh.read().splitlines()
|
|
|
|
has_enforcing = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("SELINUX=") and not stripped.startswith("#"):
|
|
if stripped.split("=", 1)[1].strip().lower() == "enforcing":
|
|
has_enforcing = True
|
|
break
|
|
|
|
if not has_enforcing:
|
|
if dry_run:
|
|
_status_done("would update to SELINUX=enforcing")
|
|
else:
|
|
new_lines: list[str] = []
|
|
found = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("SELINUX=") and not stripped.startswith("#"):
|
|
if not found:
|
|
new_lines.append("SELINUX=enforcing")
|
|
found = True
|
|
# Drop any duplicate active SELINUX= lines.
|
|
else:
|
|
new_lines.append(line)
|
|
if not found:
|
|
new_lines.append("SELINUX=enforcing")
|
|
with open("/etc/selinux/config", "w", encoding="utf-8") as fh:
|
|
fh.write("\n".join(new_lines) + "\n")
|
|
_status_done("updated to enforcing")
|
|
info["config_changed"] = True
|
|
else:
|
|
_status_done("already enforcing")
|
|
except OSError as exc:
|
|
_status_done("error")
|
|
_warn(f"Cannot read/write /etc/selinux/config: {exc}")
|
|
|
|
return info
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Summary
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def print_summary(
|
|
os_display: str,
|
|
version_id: str,
|
|
results: dict[str, Any],
|
|
warnings: list[str],
|
|
elapsed: float,
|
|
dry_run: bool = False,
|
|
) -> None:
|
|
"""Print a final summary of all sections to stderr.
|
|
|
|
In dry-run mode nothing is phrased as accomplished — only "would …".
|
|
"""
|
|
print(f"\n{BOLD}══ Setup Summary ══{RESET}", file=sys.stderr)
|
|
print(f" OS: {os_display} {version_id}", file=sys.stderr)
|
|
|
|
# System update
|
|
ru = results.get("update", {})
|
|
if ru.get("error"):
|
|
_fail("System update failed")
|
|
elif ru.get("would_update"):
|
|
_info("Would apply system updates")
|
|
elif ru.get("updated"):
|
|
_ok("System updated")
|
|
elif ru.get("skipped"):
|
|
_info("System already up to date")
|
|
elif ru:
|
|
_info("System update skipped")
|
|
|
|
# Remove packages
|
|
rr = results.get("remove", {})
|
|
if rr:
|
|
removed = len(rr.get("removed", []))
|
|
r_skipped = len(rr.get("skipped", []))
|
|
if dry_run:
|
|
_info(f"Remove: would remove {removed}, {r_skipped} already absent")
|
|
elif removed:
|
|
_ok(f"Removed: {removed} package(s), {r_skipped} already absent")
|
|
else:
|
|
_info(f"Remove: {r_skipped} package(s) already absent")
|
|
|
|
# Repos
|
|
rp = results.get("repos", {})
|
|
if rp:
|
|
r_added = len(rp.get("added", []))
|
|
rp_skipped = len(rp.get("skipped", []))
|
|
if dry_run:
|
|
_info(f"Repos: would add {r_added}, {rp_skipped} already present")
|
|
else:
|
|
_ok(f"Repos: {r_added} added, {rp_skipped} already present")
|
|
|
|
# RPM packages
|
|
rpkg = results.get("rpm", {})
|
|
if rpkg:
|
|
r_installed = len(rpkg.get("installed", []))
|
|
r_skip = len(rpkg.get("skipped", []))
|
|
r_fail = len(rpkg.get("failed", []))
|
|
if dry_run:
|
|
_info(f"RPM packages: would install {r_installed}, {r_skip} already present")
|
|
else:
|
|
_ok(f"RPM packages: {r_skip} already present, {r_installed} installed")
|
|
if r_fail:
|
|
_fail(f" {r_fail} package(s) failed to install")
|
|
|
|
# Curl-based / packaged dev tools
|
|
for section, label in (("bun", "Bun"), ("zed", "Zed"), ("uv", "uv"), ("rust", "Rust")):
|
|
rt = results.get(section, {})
|
|
if not rt:
|
|
continue
|
|
if rt.get("version"):
|
|
_ok(f"{label}: {rt['version']}")
|
|
elif rt.get("planned"):
|
|
_info(f"{label}: would be installed")
|
|
elif rt.get("installed"):
|
|
_ok(f"{label}: installed")
|
|
else:
|
|
_info(f"{label}: not installed")
|
|
|
|
# Flatpak
|
|
rf = results.get("flatpak", {})
|
|
if rf:
|
|
f_installed = len(rf.get("installed", []))
|
|
f_skipped = len(rf.get("skipped", []))
|
|
f_failed = len(rf.get("failed", []))
|
|
if dry_run:
|
|
_info(f"Flatpak: would install {f_installed}, {f_skipped} already present")
|
|
else:
|
|
_ok(f"Flatpak: {f_skipped} already present, {f_installed} installed")
|
|
if f_failed:
|
|
_fail(f" {f_failed} app(s) failed to install")
|
|
|
|
# SSH + Git
|
|
rsh = results.get("ssh", {})
|
|
if rsh:
|
|
if rsh.get("skipped"):
|
|
_info("SSH + Git: skipped")
|
|
elif rsh.get("dry_run"):
|
|
_info("SSH + Git: would be configured (key, ssh config, git identity)")
|
|
else:
|
|
key_status = (
|
|
"generated"
|
|
if rsh.get("key_generated")
|
|
else ("exists" if rsh.get("key_existed") else "not set up")
|
|
)
|
|
config_status = "updated" if rsh.get("config_updated") else "OK"
|
|
git_status = "configured" if rsh.get("git_configured") else "OK"
|
|
_ok(f"SSH + Git: key {key_status}, config {config_status}, git {git_status}")
|
|
|
|
# Firewall
|
|
rfw = results.get("firewall", {})
|
|
if rfw:
|
|
if dry_run:
|
|
_info("Firewall: would ensure firewalld is installed, enabled and running")
|
|
elif rfw.get("running"):
|
|
_ok("Firewall: installed & running")
|
|
else:
|
|
_fail("Firewall: not running")
|
|
|
|
# SELinux
|
|
rs = results.get("selinux", {})
|
|
if rs:
|
|
mode = rs.get("current_mode", "?")
|
|
if rs.get("reboot_required"):
|
|
_warn(f"SELinux: mode={mode} — config set to enforcing, REBOOT required")
|
|
else:
|
|
_ok(f"SELinux: mode={mode}")
|
|
|
|
if warnings:
|
|
print(file=sys.stderr)
|
|
for w in warnings:
|
|
_warn(w)
|
|
|
|
print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
|
|
print(f" {BOLD}Total time: {elapsed:.1f}s{RESET}", file=sys.stderr)
|
|
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
|
|
print(file=sys.stderr)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
"""Parse command-line arguments."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Idempotent workstation setup for Fedora",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print what would be done without making changes",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-update",
|
|
action="store_true",
|
|
help="Skip system update",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-rpm",
|
|
action="store_true",
|
|
help="Skip RPM package installation",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-flatpak",
|
|
action="store_true",
|
|
help="Skip Flatpak apps",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-ssh",
|
|
action="store_true",
|
|
help="Skip SSH and Git setup",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-repos",
|
|
action="store_true",
|
|
help="Skip adding third-party repos",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-remove",
|
|
action="store_true",
|
|
help="Skip removing pre-installed apps",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-bun",
|
|
action="store_true",
|
|
help="Skip Bun installation",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-zed",
|
|
action="store_true",
|
|
help="Skip Zed editor installation",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-uv",
|
|
action="store_true",
|
|
help="Skip uv installation",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-rust",
|
|
action="store_true",
|
|
help="Skip Rust toolchain installation",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-firewall",
|
|
action="store_true",
|
|
help="Skip firewall setup",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-selinux",
|
|
action="store_true",
|
|
help="Skip SELinux setup",
|
|
)
|
|
parser.add_argument(
|
|
"--version",
|
|
action="version",
|
|
version=f"%(prog)s {VERSION}",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point — parse args, detect OS, then run enabled sections."""
|
|
start_time = time.monotonic()
|
|
warnings: list[str] = []
|
|
results: dict[str, Any] = {}
|
|
|
|
# Parsed first so --help/--version work on any OS, root or not.
|
|
args = parse_args()
|
|
|
|
os_id, os_display, version_id = detect_os()
|
|
|
|
# Header
|
|
print(file=sys.stderr)
|
|
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
|
|
print(
|
|
f"{BOLD} Workstation Setup v{VERSION} — {os_display} {version_id}{RESET}",
|
|
file=sys.stderr,
|
|
)
|
|
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
|
|
if args.dry_run:
|
|
print(
|
|
f"\n {YELLOW}{BOLD}DRY RUN — no changes will be made{RESET}",
|
|
file=sys.stderr,
|
|
)
|
|
print(file=sys.stderr)
|
|
|
|
# 1. System update
|
|
print(f"\n{BOLD}── System Update ──{RESET}", file=sys.stderr)
|
|
if not args.skip_update:
|
|
results["update"] = system_update(dry_run=args.dry_run)
|
|
else:
|
|
_info("System update: skipped (--skip-update)")
|
|
|
|
# 2. Remove pre-installed apps
|
|
print(f"\n{BOLD}── Remove Pre-installed Apps ──{RESET}", file=sys.stderr)
|
|
if not args.skip_remove:
|
|
results["remove"] = remove_packages(dry_run=args.dry_run)
|
|
else:
|
|
_info("Remove apps: skipped (--skip-remove)")
|
|
|
|
# 3. Third-party repos
|
|
print(f"\n{BOLD}── Repositories ──{RESET}", file=sys.stderr)
|
|
if not args.skip_repos:
|
|
results["repos"] = setup_repos(dry_run=args.dry_run)
|
|
else:
|
|
_info("Repos: skipped (--skip-repos)")
|
|
|
|
# 4. RPM packages
|
|
print(f"\n{BOLD}── RPM Packages ──{RESET}", file=sys.stderr)
|
|
if not args.skip_rpm:
|
|
results["rpm"] = install_rpm_packages(dry_run=args.dry_run)
|
|
if results["rpm"].get("failed"):
|
|
warnings.append(
|
|
f"Some RPM packages failed to install: {', '.join(results['rpm']['failed'])}"
|
|
)
|
|
else:
|
|
_info("RPM packages: skipped (--skip-rpm)")
|
|
|
|
# 5. Dev tool installers
|
|
print(f"\n{BOLD}── Dev Tool Installers ──{RESET}", file=sys.stderr)
|
|
if not args.skip_bun:
|
|
results["bun"] = setup_bun(dry_run=args.dry_run)
|
|
else:
|
|
_info("Bun: skipped (--skip-bun)")
|
|
|
|
if not args.skip_zed:
|
|
results["zed"] = setup_zed(dry_run=args.dry_run)
|
|
else:
|
|
_info("Zed: skipped (--skip-zed)")
|
|
|
|
if not args.skip_uv:
|
|
results["uv"] = setup_uv(dry_run=args.dry_run)
|
|
else:
|
|
_info("uv: skipped (--skip-uv)")
|
|
|
|
# 6. Rust toolchain
|
|
print(f"\n{BOLD}── Rust Toolchain ──{RESET}", file=sys.stderr)
|
|
if not args.skip_rust:
|
|
results["rust"] = setup_rust(dry_run=args.dry_run)
|
|
else:
|
|
_info("Rust: skipped (--skip-rust)")
|
|
|
|
# 7. Flatpak apps
|
|
print(f"\n{BOLD}── Flatpak Apps ──{RESET}", file=sys.stderr)
|
|
if not args.skip_flatpak:
|
|
results["flatpak"] = setup_flatpak(dry_run=args.dry_run)
|
|
if results["flatpak"].get("failed"):
|
|
warnings.append(
|
|
f"Some Flatpak apps failed to install: {', '.join(results['flatpak']['failed'])}"
|
|
)
|
|
else:
|
|
_info("Flatpak: skipped (--skip-flatpak)")
|
|
|
|
# 8. SSH + Git
|
|
print(f"\n{BOLD}── SSH + Git ──{RESET}", file=sys.stderr)
|
|
if not args.skip_ssh:
|
|
results["ssh"] = setup_ssh(dry_run=args.dry_run)
|
|
else:
|
|
_info("SSH + Git: skipped (--skip-ssh)")
|
|
|
|
# 9. Firewall
|
|
print(f"\n{BOLD}── Firewall ──{RESET}", file=sys.stderr)
|
|
if not args.skip_firewall:
|
|
results["firewall"] = setup_firewall(dry_run=args.dry_run)
|
|
else:
|
|
_info("Firewall: skipped (--skip-firewall)")
|
|
|
|
# 10. SELinux
|
|
print(f"\n{BOLD}── SELinux ──{RESET}", file=sys.stderr)
|
|
if not args.skip_selinux:
|
|
results["selinux"] = setup_selinux(dry_run=args.dry_run)
|
|
else:
|
|
_info("SELinux: skipped (--skip-selinux)")
|
|
|
|
elapsed = time.monotonic() - start_time
|
|
print_summary(os_display, version_id, results, warnings, elapsed, dry_run=args.dry_run)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
print("\nInterrupted.", file=sys.stderr)
|
|
sys.exit(130)
|