runv-server

server tooling for runv.club
Log | Files | Refs | README

starthere.py (23422B)


      1 #!/usr/bin/env python3
      2 """
      3 starthere.py - bootstrap seguro para runv.club em Debian/ext4
      4 
      5 O que faz:
      6 - atualiza índices APT
      7 - instala um conjunto conservador de pacotes úteis para o projeto
      8 - faz limpeza segura (autoremove + autoclean)
      9 - enable/start apache2; UFW inativo → allow SSH/80/443 e enable
     10 - descobre automaticamente o filesystem que contém /home (pode ser / ou /home, etc.)
     11 - habilita usrquota nesse mountpoint ext4 no /etc/fstab
     12 - remount + quotacheck + quotaon nesse mesmo ponto
     13 - roda quotacheck (-cu, depois -cuM, depois variantes com -f se quotas já ativas no mount)
     14 - quotaon -vu trata EBUSY (quotas já ativas após remount) como sucesso com aviso
     15 - ativa quotas de usuário
     16 
     17 O que NÃO faz:
     18 - não purga pacotes arbitrariamente
     19 - não mexe em SSH
     20 - não mexe no Apache além de instalar o pacote se faltar
     21 - não cria usuários
     22 - não instala stack de email
     23 """
     24 
     25 from __future__ import annotations
     26 
     27 import argparse
     28 import os
     29 import shlex
     30 import shutil
     31 import subprocess
     32 import sys
     33 from dataclasses import dataclass
     34 from datetime import datetime
     35 from pathlib import Path
     36 from typing import Final
     37 
     38 # Com python3 -P ou PYTHONSAFEPATH=1 o diretório deste script deixa de ir para sys.path;
     39 # sem isto, «from runv_mount» falha mesmo com runv_mount.py ao lado.
     40 _SCRIPT_DIR = Path(__file__).resolve().parent
     41 if str(_SCRIPT_DIR) not in sys.path:
     42     sys.path.insert(0, str(_SCRIPT_DIR))
     43 
     44 from admin_guard import ensure_admin_cli
     45 
     46 try:
     47     from runv_mount import MountLookupError, find_mount_triple
     48 except ModuleNotFoundError:
     49     print(
     50         "Erro: módulo 'runv_mount' não encontrado. "
     51         "O ficheiro runv_mount.py tem de estar no mesmo diretório que starthere.py.\n"
     52         f"  Esperado: {_SCRIPT_DIR / 'runv_mount.py'}",
     53         file=sys.stderr,
     54     )
     55     raise SystemExit(1) from None
     56 
     57 DEFAULT_QUOTA_PROBE: Final[Path] = Path("/home")
     58 
     59 VERSION: Final[str] = "0.02"
     60 
     61 FSTAB = Path("/etc/fstab")
     62 BACKUP_DIR = Path("/root/runv-fstab-backups")
     63 
     64 BASE_PACKAGES = [
     65     "apache2",
     66     "openssh-server",
     67     "sudo",
     68     "ufw",
     69     "quota",
     70     "curl",
     71     "wget",
     72     "git",
     73     "rsync",
     74     "tmux",
     75     "htop",
     76     "vim",
     77     "nano",
     78     "tree",
     79     "jq",
     80     "acl",
     81     "zip",
     82     "unzip",
     83     "less",
     84     "ca-certificates",
     85     "man-db",
     86     "build-essential",
     87     "python3-venv",
     88     "python3-pip",
     89     "ripgrep",
     90     "shellcheck",
     91     "e2fsprogs",
     92 ]
     93 
     94 @dataclass
     95 class CmdResult:
     96     cmd: list[str]
     97     returncode: int
     98     stdout: str
     99     stderr: str
    100 
    101 
    102 class BootstrapError(RuntimeError):
    103     pass
    104 
    105 
    106 def eprint(msg: str) -> None:
    107     print(msg, file=sys.stderr)
    108 
    109 
    110 def require_root() -> None:
    111     if os.geteuid() != 0:
    112         raise BootstrapError("Este script precisa rodar como root (use sudo).")
    113 
    114 
    115 def run(
    116     cmd: list[str],
    117     *,
    118     dry_run: bool = False,
    119     verbose: bool = False,
    120     check: bool = True,
    121     env: dict[str, str] | None = None,
    122 ) -> CmdResult:
    123     if verbose or dry_run:
    124         eprint("$ " + " ".join(shlex.quote(part) for part in cmd))
    125     if dry_run:
    126         return CmdResult(cmd, 0, "", "")
    127     proc = subprocess.run(
    128         cmd,
    129         text=True,
    130         capture_output=True,
    131         env=env,
    132         check=False,
    133     )
    134     if verbose and proc.stdout:
    135         eprint(proc.stdout.rstrip())
    136     if verbose and proc.stderr:
    137         eprint(proc.stderr.rstrip())
    138     if check and proc.returncode != 0:
    139         raise BootstrapError(
    140             f"Comando falhou ({proc.returncode}): {' '.join(cmd)}\n"
    141             f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}"
    142         )
    143     return CmdResult(cmd, proc.returncode, proc.stdout, proc.stderr)
    144 
    145 
    146 def command_exists(name: str) -> bool:
    147     return shutil.which(name) is not None
    148 
    149 
    150 def apt_env() -> dict[str, str]:
    151     env = os.environ.copy()
    152     env["DEBIAN_FRONTEND"] = "noninteractive"
    153     return env
    154 
    155 
    156 def apt_update(verbose: bool, dry_run: bool) -> None:
    157     run(["apt-get", "update"], verbose=verbose, dry_run=dry_run, env=apt_env())
    158 
    159 
    160 def apt_install(packages: list[str], verbose: bool, dry_run: bool) -> None:
    161     run(
    162         ["apt-get", "install", "-y", *packages],
    163         verbose=verbose,
    164         dry_run=dry_run,
    165         env=apt_env(),
    166     )
    167 
    168 
    169 def apt_cleanup(verbose: bool, dry_run: bool) -> None:
    170     run(["apt-get", "autoremove", "-y"], verbose=verbose, dry_run=dry_run, env=apt_env())
    171     run(["apt-get", "autoclean", "-y"], verbose=verbose, dry_run=dry_run, env=apt_env())
    172 
    173 
    174 def get_mount_kernel_view(mountpoint: str, *, verbose: bool) -> tuple[str, str, list[str]]:
    175     """Lê TARGET,FSTYPE,OPTIONS do kernel para um mountpoint (ex. ``/`` ou ``/home``)."""
    176     res = run(
    177         ["findmnt", "-no", "TARGET,FSTYPE,OPTIONS", mountpoint],
    178         verbose=verbose,
    179         dry_run=False,
    180     )
    181     line = res.stdout.strip()
    182     if not line:
    183         raise BootstrapError(
    184             f"Não consegui obter informações do mount {mountpoint!r} com findmnt."
    185         )
    186     parts = line.split(maxsplit=2)
    187     if len(parts) != 3:
    188         raise BootstrapError(f"Saída inesperada do findmnt: {line!r}")
    189     target, fstype, options = parts
    190     opts_list = [opt.strip() for opt in options.split(",") if opt.strip()]
    191     return target, fstype, opts_list
    192 
    193 
    194 def mount_options_indicate_user_quota(options: list[str]) -> bool:
    195     """usrquota explícito ou journaled (usrjquota=...)."""
    196     blob = ",".join(options)
    197     return "usrquota" in blob or "usrjquota" in blob
    198 
    199 
    200 def discover_quota_mountpoint(home_probe: Path, verbose: bool) -> str:
    201     """
    202     O mesmo critério que create_runv_user / setquota: filesystem que contém o path de sonda
    203     (por omissão ``/home``). Pode ser ``/`` ou ``/home`` (volume dedicado), etc.
    204     """
    205     try:
    206         tgt, fst, opts_csv = find_mount_triple(home_probe)
    207     except MountLookupError as e:
    208         raise BootstrapError(
    209             f"Não foi possível descobrir em que filesystem {home_probe} está montado. "
    210             f"Detalhe: {e}"
    211         ) from e
    212     if verbose:
    213         eprint(
    214             f"Deteção automática: {home_probe} → mount {tgt!r}, fstype {fst}, opções {opts_csv!r}"
    215         )
    216     if fst != "ext4":
    217         raise BootstrapError(
    218             f"O filesystem que contém {home_probe} está em {tgt!r} com tipo {fst!r}. "
    219             "Só configuramos quotas automaticamente para ext4 (alinhado a create_runv_user.py). "
    220             "Noutro tipo de FS, configure quotas manualmente ou use uma VPS com /home em ext4."
    221         )
    222     return tgt
    223 
    224 
    225 def backup_fstab(verbose: bool, dry_run: bool) -> Path:
    226     timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    227     backup_path = BACKUP_DIR / f"fstab.{timestamp}.bak"
    228     if verbose or dry_run:
    229         eprint(f"Backup do fstab: {backup_path}")
    230     if not dry_run:
    231         BACKUP_DIR.mkdir(parents=True, exist_ok=True)
    232         shutil.copy2(FSTAB, backup_path)
    233     return backup_path
    234 
    235 
    236 def ensure_usrquota_in_fstab(mountpoint: str, *, dry_run: bool, verbose: bool) -> bool:
    237     """
    238     Garante usrquota na linha do fstab que monta ``mountpoint`` como ext4.
    239     Retorna True se o arquivo foi alterado.
    240     """
    241     content = FSTAB.read_text(encoding="utf-8").splitlines(keepends=True)
    242     changed = False
    243     new_lines: list[str] = []
    244 
    245     for line in content:
    246         stripped = line.strip()
    247         if not stripped or stripped.startswith("#"):
    248             new_lines.append(line)
    249             continue
    250 
    251         parts = line.split()
    252         if len(parts) < 4:
    253             new_lines.append(line)
    254             continue
    255 
    256         _device, mp, fstype, options = parts[:4]
    257         if mp == mountpoint and fstype == "ext4":
    258             opts = [o for o in options.split(",") if o]
    259             if "usrquota" not in opts:
    260                 opts.append("usrquota")
    261                 parts[3] = ",".join(opts)
    262                 newline = "\t".join(parts)
    263                 if not newline.endswith("\n"):
    264                     newline += "\n"
    265                 new_lines.append(newline)
    266                 changed = True
    267                 continue
    268         new_lines.append(line)
    269 
    270     if changed:
    271         backup_fstab(verbose, dry_run)
    272         if verbose or dry_run:
    273             eprint(f"Atualizando /etc/fstab para incluir usrquota em {mountpoint!r}")
    274         if not dry_run:
    275             FSTAB.write_text("".join(new_lines), encoding="utf-8")
    276     else:
    277         if verbose:
    278             eprint(f"/etc/fstab já contém usrquota para {mountpoint!r} (ext4)")
    279     return changed
    280 
    281 
    282 def remount_with_usrquota(mountpoint: str, verbose: bool, dry_run: bool) -> None:
    283     run(
    284         ["mount", "-o", "remount,usrquota", mountpoint],
    285         verbose=verbose,
    286         dry_run=dry_run,
    287     )
    288     if dry_run or mount_has_user_quota(mountpoint, verbose):
    289         return
    290     if verbose:
    291         eprint(
    292             f"Aviso: usrquota ainda não aparece em {mountpoint!r}; "
    293             "tentando remount genérico..."
    294         )
    295     run(["mount", "-o", "remount", mountpoint], verbose=verbose, dry_run=dry_run)
    296 
    297 
    298 def mount_has_user_quota(mountpoint: str, verbose: bool = False) -> bool:
    299     _, _, options = get_mount_kernel_view(mountpoint, verbose=verbose)
    300     return mount_options_indicate_user_quota(options)
    301 
    302 
    303 def dry_run_assume_quota_active(
    304     *,
    305     dry_run: bool,
    306     fstab_changed: bool,
    307     skip_remount: bool,
    308 ) -> bool:
    309     """
    310     Em --dry-run não escrevemos fstab nem remontamos de verdade; o findmnt
    311     continua sem usrquota. Assumimos sucesso só para completar o plano de quotas.
    312     """
    313     if not dry_run:
    314         return False
    315     if skip_remount and fstab_changed:
    316         eprint(
    317             "AVISO (dry-run): com --skip-remount e fstab a alterar, "
    318             "em execução real seria preciso remount ou reboot antes de quotacheck."
    319         )
    320     return True
    321 
    322 
    323 def quota_mount_ready(
    324     mountpoint: str,
    325     verbose: bool,
    326     *,
    327     dry_run: bool,
    328     dry_run_trust: bool,
    329 ) -> bool:
    330     if dry_run_trust:
    331         return True
    332     return mount_has_user_quota(mountpoint, verbose)
    333 
    334 
    335 def quota_tools_present() -> list[str]:
    336     required = ["quotacheck", "quotaon", "setquota", "quota"]
    337     return [tool for tool in required if command_exists(tool)]
    338 
    339 
    340 def run_quotacheck_escalation(
    341     mountpoint: str,
    342     *,
    343     verbose: bool,
    344     dry_run: bool,
    345     allow_live_scan: bool,
    346 ) -> None:
    347     """
    348     Cria/atualiza aquota.* com quotacheck.
    349 
    350     Após ``remount,usrquota``, o kernel pode já reportar quotas de utilizador
    351     ativas; nesse caso o quotacheck recusa-se sem ``-f`` («use -f to force»).
    352     Tentamos primeiro varreduras normais e só depois variantes com ``-f``.
    353     """
    354     if allow_live_scan:
    355         sequences: list[tuple[list[str], str]] = [
    356             (["quotacheck", "-cuM", mountpoint], "quotacheck -cuM"),
    357             (["quotacheck", "-cuM", "-f", mountpoint], "quotacheck -cuM -f"),
    358         ]
    359     else:
    360         sequences = [
    361             (["quotacheck", "-cu", mountpoint], "quotacheck -cu"),
    362             (["quotacheck", "-cuM", mountpoint], "quotacheck -cuM"),
    363             (["quotacheck", "-cuM", "-f", mountpoint], "quotacheck -cuM -f"),
    364             (["quotacheck", "-cu", "-f", mountpoint], "quotacheck -cu -f"),
    365         ]
    366 
    367     last_exc: BootstrapError | None = None
    368     for i, (cmd, label) in enumerate(sequences):
    369         try:
    370             run(cmd, verbose=verbose, dry_run=dry_run)
    371             return
    372         except BootstrapError as exc:
    373             last_exc = exc
    374             if dry_run:
    375                 raise
    376             if i + 1 < len(sequences):
    377                 eprint(f"{label} falhou; a tentar método seguinte...")
    378 
    379     assert last_exc is not None
    380     tried = ", ".join(label for _cmd, label in sequences)
    381     raise BootstrapError(
    382         f"quotacheck falhou após tentar: {tried}.\nÚltimo erro:\n{last_exc}\n"
    383         "Se a mensagem falar de quotas nativas ext4 (tune2fs -O quota) vs ficheiros "
    384         "aquota.*, veja a secção de quotas em starthere.md; em muitos casos "
    385         "-f resolve após remount com usrquota já ativo."
    386     ) from last_exc
    387 
    388 
    389 def _quotaon_stderr_implies_already_active(text: str) -> bool:
    390     """EBUSY típico quando usrquota já ficou ativo no remount e aquota.* está em uso."""
    391     t = text.lower()
    392     return "device or resource busy" in t or (
    393         "resource busy" in t and "quotaon" in t
    394     )
    395 
    396 
    397 def run_quotaon_user_vu(mountpoint: str, *, verbose: bool, dry_run: bool) -> None:
    398     """
    399     Executa ``quotaon -vu``. Se falhar com EBUSY, assume quotas de utilizador
    400     já ativas (comum após ``remount,usrquota`` + quotacheck) e continua.
    401     """
    402     res = run(
    403         ["quotaon", "-vu", mountpoint],
    404         verbose=verbose,
    405         dry_run=dry_run,
    406         check=False,
    407     )
    408     if dry_run:
    409         return
    410     if res.returncode == 0:
    411         return
    412     combined = (res.stderr or "") + (res.stdout or "")
    413     if _quotaon_stderr_implies_already_active(combined):
    414         qmp = shlex.quote(mountpoint)
    415         eprint(
    416             "quotaon: quotas de utilizador já ativas neste mount (Device or resource busy); "
    417             f"a continuar. Confirme com «quota -vs» ou «sudo repquota -s {qmp}»."
    418         )
    419         return
    420     raise BootstrapError(
    421         f"Comando falhou ({res.returncode}): quotaon -vu {shlex.quote(mountpoint)}\n"
    422         f"STDOUT:\n{res.stdout}\nSTDERR:\n{res.stderr}"
    423     )
    424 
    425 
    426 def init_quotas(
    427     mountpoint: str,
    428     verbose: bool,
    429     dry_run: bool,
    430     *,
    431     allow_live_scan: bool,
    432 ) -> None:
    433     present = quota_tools_present()
    434     missing = [tool for tool in ["quotacheck", "quotaon", "setquota", "quota"] if tool not in present]
    435     if missing:
    436         raise BootstrapError(
    437             "Ferramentas de quota ausentes mesmo após instalar o pacote quota: "
    438             + ", ".join(missing)
    439         )
    440 
    441     if not dry_run and not mount_has_user_quota(mountpoint, False):
    442         raise BootstrapError(
    443             f"O mount {mountpoint!r} ainda não mostra usrquota ativo. "
    444             "Reinicie a VM ou confirme o remount antes de prosseguir."
    445         )
    446 
    447     run_quotacheck_escalation(
    448         mountpoint,
    449         verbose=verbose,
    450         dry_run=dry_run,
    451         allow_live_scan=allow_live_scan,
    452     )
    453 
    454     run_quotaon_user_vu(mountpoint, verbose=verbose, dry_run=dry_run)
    455 
    456 
    457 def block_device_for_mount(mountpoint: str) -> str | None:
    458     res = run(
    459         ["findmnt", "-no", "SOURCE", mountpoint],
    460         verbose=False,
    461         dry_run=False,
    462         check=False,
    463     )
    464     if res.returncode != 0:
    465         return None
    466     dev = res.stdout.strip()
    467     if not dev or dev == "none":
    468         return None
    469     return dev
    470 
    471 
    472 def ext4_has_internal_quota_feature(device: str) -> bool | None:
    473     """True se `tune2fs -l` lista a feature «quota» (quotas nativas ext4)."""
    474     proc = subprocess.run(
    475         ["tune2fs", "-l", device],
    476         text=True,
    477         capture_output=True,
    478         check=False,
    479         timeout=60,
    480     )
    481     if proc.returncode != 0:
    482         return None
    483     for line in proc.stdout.splitlines():
    484         if line.startswith("Filesystem features:"):
    485             _label, _, rest = line.partition(":")
    486             parts = rest.split()
    487             return "quota" in parts
    488     return False
    489 
    490 
    491 def note_ext4_quota_deprecation_context(mountpoint: str) -> None:
    492     """
    493     Explica os avisos «external quota files» / tune2fs -O quota após sucesso.
    494     """
    495     dev = block_device_for_mount(mountpoint)
    496     if not dev:
    497         return
    498     internal = ext4_has_internal_quota_feature(dev)
    499     if internal is True:
    500         return
    501     eprint(
    502         "Nota (ext4): os avisos de quotacheck/quotaon sobre «external quota files» "
    503         "e «tune2fs -O quota» aparecem quando a feature interna «quota» do ext4 "
    504         "ainda não está ligada no dispositivo — o script usa o modo clássico "
    505         "(usrquota + aquota.*), que continua válido; confirme com «quota -vs». "
    506         "Migrar para o modo recomendado pelo kernel exige janela de manutenção "
    507         f"(desmontar {dev}, «tune2fs -O quota», remontar); ver starthere.md."
    508     )
    509 
    510 
    511 def ufw_status_text() -> str:
    512     proc = subprocess.run(
    513         ["ufw", "status"],
    514         text=True,
    515         capture_output=True,
    516         check=False,
    517     )
    518     return (proc.stdout or "") + (proc.stderr or "")
    519 
    520 
    521 def configure_ufw(verbose: bool, dry_run: bool) -> None:
    522     """Habilita UFW só se estiver inativo; preserva SSH antes de fechar."""
    523     if not command_exists("ufw"):
    524         eprint("AVISO: comando ufw ausente; instale o pacote ufw ou não use --no-install.")
    525         return
    526     txt = ufw_status_text().lower()
    527     if "inactive" not in txt:
    528         if verbose:
    529             eprint("UFW já está ativo ou estado não reconhecido; não altero regras.")
    530         return
    531     if verbose or dry_run:
    532         eprint("UFW inativo: permitindo SSH, HTTP, HTTPS e ativando.")
    533     run(["ufw", "allow", "OpenSSH"], verbose=verbose, dry_run=dry_run)
    534     run(["ufw", "allow", "80/tcp"], verbose=verbose, dry_run=dry_run)
    535     run(["ufw", "allow", "443/tcp"], verbose=verbose, dry_run=dry_run)
    536     run(["ufw", "--force", "enable"], verbose=verbose, dry_run=dry_run)
    537 
    538 
    539 def configure_apache(verbose: bool, dry_run: bool) -> None:
    540     if not command_exists("systemctl"):
    541         eprint("AVISO: systemctl ausente; não configurei Apache.")
    542         return
    543     run(["systemctl", "enable", "apache2"], verbose=verbose, dry_run=dry_run)
    544     run(["systemctl", "start", "apache2"], verbose=verbose, dry_run=dry_run)
    545 
    546 
    547 def build_parser() -> argparse.ArgumentParser:
    548     p = argparse.ArgumentParser(
    549         description="Bootstrap seguro do servidor runv.club (Debian/ext4 + quotas)."
    550     )
    551     p.add_argument("--dry-run", action="store_true", help="Mostra o plano sem executar.")
    552     p.add_argument("--verbose", action="store_true", help="Mostra mais detalhes.")
    553     p.add_argument(
    554         "--packages",
    555         nargs="*",
    556         default=BASE_PACKAGES,
    557         help="Lista de pacotes a instalar (padrão conservador incluído).",
    558     )
    559     p.add_argument(
    560         "--no-cleanup",
    561         action="store_true",
    562         help="Pula apt autoremove/autoclean.",
    563     )
    564     p.add_argument(
    565         "--no-install",
    566         action="store_true",
    567         help="Não instala pacotes; só verifica/configura quotas.",
    568     )
    569     p.add_argument(
    570         "--no-quota",
    571         action="store_true",
    572         help="Não mexe em quotas; só instala pacotes e faz limpeza segura.",
    573     )
    574     p.add_argument(
    575         "--quota-probe",
    576         type=Path,
    577         default=DEFAULT_QUOTA_PROBE,
    578         metavar="PATH",
    579         help=(
    580             "Caminho para descobrir o filesystem de quotas (deve refletir onde ficam as homes "
    581             f"runv; predefinido: {DEFAULT_QUOTA_PROBE})."
    582         ),
    583     )
    584     p.add_argument(
    585         "--skip-remount",
    586         action="store_true",
    587         help="Não tenta remount após editar /etc/fstab.",
    588     )
    589     p.add_argument(
    590         "--allow-live-scan",
    591         action="store_true",
    592         help=(
    593             "Usa só quotacheck -cuM (sem tentar antes -cu). "
    594             "Por omissão o script já tenta -cu e cai para -cuM se necessário."
    595         ),
    596     )
    597     p.add_argument(
    598         "--no-services",
    599         action="store_true",
    600         help="Não ativa Apache nem configura/ativa UFW (pacotes podem ser instalados).",
    601     )
    602     p.add_argument(
    603         "--version",
    604         action="version",
    605         version=f"%(prog)s {VERSION} — runv.club",
    606     )
    607     return p
    608 
    609 
    610 def main() -> int:
    611     parser = build_parser()
    612     args = parser.parse_args()
    613     ensure_admin_cli(
    614         script_name=Path(__file__).name,
    615         dry_run=bool(args.dry_run),
    616     )
    617 
    618     try:
    619         require_root()
    620 
    621         quota_mp: str | None = None
    622         if not args.no_quota:
    623             quota_mp = discover_quota_mountpoint(args.quota_probe, args.verbose)
    624 
    625         print(f"== runv.club / starthere.py v{VERSION} ==")
    626         print("Bootstrap conservador para Debian/ext4.")
    627         if quota_mp is not None:
    628             print(
    629                 f"Quotas: mount detetado para {args.quota_probe} → {quota_mp!r} (ext4, alinhado a create_runv_user)."
    630             )
    631         print()
    632 
    633         print("[1/6] Atualizando índices APT...")
    634         if not args.no_install:
    635             apt_update(args.verbose, args.dry_run)
    636 
    637         print("[2/6] Instalando pacotes-base...")
    638         if not args.no_install:
    639             apt_install(args.packages, args.verbose, args.dry_run)
    640 
    641         print("[3/6] Limpeza segura...")
    642         if not args.no_cleanup and not args.no_install:
    643             apt_cleanup(args.verbose, args.dry_run)
    644         else:
    645             print("Pulando limpeza segura.")
    646 
    647         print("[4/6] Serviços (Apache, UFW)...")
    648         if not args.no_services:
    649             configure_apache(args.verbose, args.dry_run)
    650             configure_ufw(args.verbose, args.dry_run)
    651         else:
    652             print("Pulado por --no-services.")
    653 
    654         if not args.no_quota:
    655             assert quota_mp is not None
    656             print(f"[5/6] Ajustando /etc/fstab para usrquota em {quota_mp!r} ...")
    657             changed = ensure_usrquota_in_fstab(
    658                 quota_mp,
    659                 dry_run=args.dry_run,
    660                 verbose=args.verbose,
    661             )
    662 
    663             if changed and not args.skip_remount:
    664                 print(f"Tentando remount de {quota_mp!r} com usrquota ...")
    665                 try:
    666                     remount_with_usrquota(quota_mp, args.verbose, args.dry_run)
    667                 except BootstrapError as exc:
    668                     raise BootstrapError(
    669                         f"Não consegui remount de {quota_mp!r} com usrquota.\n{exc}\n"
    670                         "Caminho recomendado: reinicie a VM e rode o script novamente."
    671                     ) from exc
    672 
    673             dry_trust = dry_run_assume_quota_active(
    674                 dry_run=args.dry_run,
    675                 fstab_changed=changed,
    676                 skip_remount=args.skip_remount,
    677             )
    678 
    679             if quota_mount_ready(
    680                 quota_mp,
    681                 args.verbose,
    682                 dry_run=args.dry_run,
    683                 dry_run_trust=dry_trust,
    684             ):
    685                 print("[6/6] Inicializando e ativando quotas...")
    686                 init_quotas(
    687                     quota_mp,
    688                     args.verbose,
    689                     args.dry_run,
    690                     allow_live_scan=args.allow_live_scan,
    691                 )
    692                 print(f"Quotas de usuário ativadas em {quota_mp!r}.")
    693                 if not args.dry_run:
    694                     note_ext4_quota_deprecation_context(quota_mp)
    695             else:
    696                 raise BootstrapError(
    697                     f"usrquota ainda não aparece ativo em {quota_mp!r}. "
    698                     "Reinicie a VM e rode o script novamente."
    699                 )
    700         else:
    701             print("[5/6] Quotas puladas por --no-quota")
    702             print("[6/6] Nada a fazer em quotas")
    703 
    704         print()
    705         print("Concluído.")
    706         print("Próximos passos:")
    707         if quota_mp is not None:
    708             print(f"- Confirmar mount de quotas:  mount | grep ' on {quota_mp} '")
    709         else:
    710             print("- Confirmar mounts e usrquota conforme a sua configuração")
    711         print("- Checar quotas:    quota -vs")
    712         print("- Contas: usar create_runv_user.py (setquota) conforme create_runv_user.md")
    713         print("- Reinício (se precisar):  sudo reboot   ou   /sbin/reboot")
    714         return 0
    715 
    716     except BootstrapError as exc:
    717         eprint(f"ERRO: {exc}")
    718         return 2
    719     except KeyboardInterrupt:
    720         eprint("Interrompido pelo usuário.")
    721         return 130
    722 
    723 
    724 if __name__ == "__main__":
    725     raise SystemExit(main())