JustToThePoint English Website Version
JustToThePoint en español

Automated Weekly Maintenance Pipeline with Python and Fabric

For every problem there is always, at least, a solution which seems quite plausible. It is simple and clean, direct, neat and nice, and yet very wrong, Anawim, justtothepoint.com

image info

Introduction

Managing a mixed environment —spanning Arch Linux for local development and NixOS for remote backups— can be tedious. That’s why I wrote weekly-tasks.py, a robust Python automation daemon designed to streamline my weekly maintenance routine.

This script acts as a comprehensive pipeline executor that runs tasks ranging from system updates and mantaining tasks, SSH backups, to Hugo site deployment and live verification. It’s built with resilience in mind: if one step fails, the pipeline logs the error and continues to the next task, ensuring that non-critical maintenance (like greetings or local server starts) isn’t blocked by a temporary network glitch.

Key features include automatic virtual environment activation, colored console logging, dry-run capabilities, and a flexible CLI that allows skipping specific steps or starting from a particular point in the sequence.

#!/usr/bin/env python3
"""
weekly-tasks.py — Automated weekly maintenance pipeline
─────────────────────────────────────────────────────────────────────────────
WHAT THIS SCRIPT DOES
    Runs six tasks in sequence. Each task is guarded — if it fails, the
    script reports the failure clearly and continues to the next task
    rather than aborting the whole pipeline.

    Tasks:
        1. Arch maintenance   bash arch-maintain.sh --auto  (includes requirements.txt update)
        2. NixOS backup       SSH → 192.168.1.49 → ~/dotfiles/scripts/mybackup.sh
        3. Hugo deploy        bash deploy.sh  (hugo build + rsync to hosting)
        4. Deployment check   verify the newest HTML is live at justtothepoint.com
        5. Hugo server        bash webserver.sh  (local preview)
        6. Link check         link_checker.py run locally from the mypython venv
        7. Greetings          today(config.root_todo)

Usage:
        python3 weekly-tasks.py             # run all steps
        python3 weekly-tasks.py --skip 2,5  # skip steps by number
        python3 weekly-tasks.py --dry-run   # print plan, execute nothing
        python3 weekly-tasks.py --from 3    # start from step 3

SCHEDULING (cron example — every Wednesday at 08:00)
    crontab -e
    0 8 * * 3  python3 /home/nmaximo7/dotfiles/myArch/admin/weekly-tasks.py

SCHEDULING (systemd timer — recommended on Arch)
    See the unit file template at the bottom of this file.

REQUIREMENTS
    pip install fabric          # SSH remote execution
    sudo pacman -S openssh      # SSH client

    The NixOS server (192.168.1.49) must:
        • Be reachable on the LAN
        • Have your SSH public key in ~/.ssh/authorized_keys
        • Have ~/dotfiles/scripts/mybackup.sh executable

DEPENDENCIES
    fabric   — pip install fabric   (paramiko-based SSH)
    All other imports are stdlib.
"""

import subprocess
import sys
import time
import argparse
import traceback
import urllib.request
import urllib.error
import os
from pathlib import Path
from datetime import datetime

from fabric import Connection

# ── Project-specific imports ──────────────────────────────────────────────────
# These are local modules living in the same Python project on the Arch machine.
# They are imported unconditionally because the script is intended to run from
# within the project directory (or after the venv guard activates).
from link_checker import check_hugo_site # link checking logic
from hugo_config   import config         # site configuration (theme, URL, etc.)
from greetings     import today          # daily greeting message generator

# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION — edit these to match your setup
# ══════════════════════════════════════════════════════════════════════════════

# Remote NixOS server connection details.
# No fallback to ssh-agent; the SSH key must be present and unlocked.
SSH_HOST   = "192.168.1.49"
SSH_USER   = "nmaximo7"
SSH_KEY    = Path.home() / ".ssh/id_rsa"   # or id_ed25519

# The admin directory on the local Arch machine.
# Contains shell scripts for maintenance and deployment.
ADMIN_DIR  = Path.home() / "dotfiles/myArch/admin"

# Live Hugo site URL.
LIVE_URL   = "https://justtothepoint.com"
# The directory where Hugo generates the site (the production public/ folder).
# This is on a separate data drive (/mnt/data).
PUBLIC_DIR = Path("/mnt/data/justtothepoint/public")

# Local Python project — link checker and greetings live here.
# The virtual environment is used to run the link checker and
# the greetings script with the correct dependencies.
LOCAL_PYTHON_DIR = Path("/mnt/data/justtothepoint/mypython")
LOCAL_VENV       = LOCAL_PYTHON_DIR / "venv"
LOCAL_PYTHON     = LOCAL_VENV / "bin" / "python3"
LOCAL_ACTIVATE   = LOCAL_VENV / "bin" / "activate"

# After a Hugo deploy, the script waits a few seconds before hitting
# the live URL to ensure Nginx has reloaded and the new content is served.
HUGO_WARMUP_SECS = 30

# ══════════════════════════════════════════════════════════════════════════════
# VENV HELPERS
# ══════════════════════════════════════════════════════════════════════════════

def is_in_venv() -> bool:
    """
    Return True if we are currently running inside a virtual environment.

    Python sets sys.prefix to the venv root when activated. Comparing it
    to sys.base_prefix (the real system Python) tells us if we're in a venv.
    sys.base_prefix is available from Python 3.3+; sys.real_prefix is set
    by older virtualenv versions, so we check both for maximum compatibility.

    This check is reliable even when the venv was activated outside the script,
    e.g., `source venv/bin/activate && python3 weekly-tasks.py`.
    """
    in_venv = (
        sys.prefix != sys.base_prefix
        or getattr(sys, "real_prefix", None) is not None
    )
    return in_venv


def ensure_venv() -> None:
    """
    If we are NOT in the project venv, re‑launch this script inside it.

    This means you can call `python3 weekly-tasks.py` from any shell —
    even without activating the venv first — and the script will
    transparently restart itself in the correct Python environment.

    WHY re‑exec instead of "activating" inside the process:
        `source venv/bin/activate` is a bash command that modifies the
        shell's PATH and environment variables. There is no equivalent
        in Python — you cannot "activate" a venv from inside a running
        Python process because sys.path and sys.prefix are already set
        at startup. The only correct way to run inside a venv is to
        exec() the venv's Python binary with the same script and arguments.

    If the venv Python is missing, a helpful warning is printed, but the
    script continues without the venv. Some steps (like the link checker)
    may fail later because their dependencies are not installed globally.
    """
    if is_in_venv():
        return   # already in the right venv — nothing to do

    if not LOCAL_PYTHON.exists():
        Log.warn(
            f"No venv found at {LOCAL_VENV}\n"
            f"  Create it: python3 -m venv {LOCAL_VENV}\n"
            f"  Install:   {LOCAL_PYTHON} -m pip install -r {LOCAL_PYTHON_DIR}/requirements.txt"
        )
        return   # continue without venv (some steps may fail, but not fatal)

    Log.info(f"Not in venv — restarting with {LOCAL_PYTHON}")
    # os.execv replaces the current process image with the new Python.
    # The original process does not continue; the new process starts
    # fresh with the correct sys.prefix and site‑packages.
    # This line replaces the current running process with a new Python interpreter — the one at LOCAL_PYTHON — and passes it the same command‑line arguments.
    # The new interpreter has the right virtualenv!!!
    os.execv(str(LOCAL_PYTHON), [str(LOCAL_PYTHON)] + sys.argv)

class Log:
    """
    Simple colourised console logger with structured formatting.

    The colours are only applied if stdout is a TTY (i.e., not piped to a
    file or systemd journal). This keeps the output readable in logs while
    providing visual cues during interactive runs.

    All methods are classmethods because the class is never instantiated —
    it serves purely as a namespace for logging functions and colour constants.
    """
    # This is a common Python pattern used to determine if a program's standard output is connected to an interactive terminal.
    # True means the output is going directly to a terminal screen where a user can see it in real-time.
    _tty = sys.stdout.isatty()

    # Colour codes (empty strings when not on a TTY).
    BOLD  = "\033[1m"    if _tty else ""
    BLUE  = "\033[34m"   if _tty else ""
    GREEN = "\033[32m"   if _tty else ""
    RED   = "\033[31m"   if _tty else ""
    YLW   = "\033[33m"   if _tty else ""
    CYN   = "\033[36m"   if _tty else ""
    RST   = "\033[0m"    if _tty else ""

    @classmethod
    def banner(cls, text: str) -> None:
        """Print a large, prominent title block (used for script header and summary)."""
        w = 64
        print()
        print(f"{cls.BOLD}{cls.BLUE}{'═' * w}{cls.RESET}")
        print(f"{cls.BOLD}{cls.BLUE}  {text}{cls.RESET}")
        print(f"{cls.BOLD}{cls.BLUE}{'═' * w}{cls.RESET}")
        print()

    @classmethod
    def step(cls, n: int, title: str) -> None:
        """Announce a pipeline step with a number and a dashed underline."""
        bar = "─" * max(0, 52 - len(title))
        print(f"\n{cls.BOLD}── Step {n}: {title} {bar}{cls.RESET}\n")

    @classmethod
    def ok(cls, msg: str)   -> None:
        """Success message with green checkmark."""
        print(f"  {cls.GREEN}✅{cls.RESET}  {msg}")

    @classmethod
    def warn(cls, msg: str) -> None:
        """Warning message with yellow warning sign."""
        print(f"  {cls.YELLOW}⚠️ {cls.RESET}  {msg}")

    @classmethod
    def fail(cls, msg: str) -> None:
        """Failure message with red cross (to stderr)."""
        print(f"  {cls.RED}❌{cls.RESET}  {msg}", file=sys.stderr)

    @classmethod
    def info(cls, msg: str) -> None:
        """Informational message with cyan info symbol."""
        print(f"  {cls.CYAN}ℹ️ {cls.RESET}  {msg}")

    @classmethod
    def detail(cls, line: str) -> None:
        """
        Print a sub‑line indented with a dim vertical bar.
        Used to display command output or additional context without
        cluttering the main log flow.
        """
        print(f"  {cls.DIM}│ {line}{cls.RESET}")

    @classmethod
    def elapsed(cls, secs: float) -> str:
        """Format a duration in seconds as a human‑readable string (e.g., '2m 35s')."""
        m, s = divmod(int(secs), 60)
        return f"{m}m {s}s" if m else f"{s}s"


# ══════════════════════════════════════════════════════════════════════════════
# RESULT TRACKING
# ══════════════════════════════════════════════════════════════════════════════

class StepResult:
    """
    Holds the outcome of a single pipeline step, to be assembled into a final summary.

    Status constants are class attributes for consistency and readability.
    Each step starts as SKIPPED until explicitly marked by the task runner.
    """

    OK      = "ok"
    FAIL    = "failed"
    WARN    = "warning"
    SKIP    = "skipped"

    def __init__(self, number: int, name: str):
        self.number   = number
        self.name     = name
        self.status   = self.SKIP   # default until proven otherwise
        self.notes    = ""          # extra details (error messages, paths, etc.)
        self.duration = 0.0         # wall‑clock time for the step

    def ok(self,   n="") -> None: self.status = self.OK;   self.notes = n
    def fail(self, n="") -> None: self.status = self.FAIL; self.notes = n
    def warn(self, n="") -> None: self.status = self.WARN; self.notes = n
    def skip(self, n="") -> None: self.status = self.SKIP; self.notes = n

# ══════════════════════════════════════════════════════════════════════════════
# RUNNERS
# ══════════════════════════════════════════════════════════════════════════════

def run_local(script_name: str, *args: str, cwd: Path | None = None,
              python: bool = False) -> tuple[bool, str]:
    """
    Run a local script and stream its output through Log.detail().

    The script is executed either as a bash script (from ADMIN_DIR) or as a
    Python script (from LOCAL_PYTHON_DIR using the venv Python). This dual
    mode keeps the pipeline configuration simple: shell maintenance scripts
    stay in the admin folder, Python tools live in the project folder.

    python=True runs it with the venv Python instead of bash.
    In that case, the script path is resolved relative to LOCAL_PYTHON_DIR,
    and the command becomes:    [args...]

    WHY separate python mode:
        Python tools (link checker, greetings) have dependencies managed by
        the venv. Running them with bash would not work; using the venv Python
        ensures the correct environment without needing to pre‑activate.

    WHY stream output in real time:
        Long‑running tasks (arch-maintain.sh, deploy.sh) produce continuous
        feedback. The user should see it as it happens, not only at the end.

    Args:
        script_name: filename of the script (e.g., "arch-maintain.sh").
        *args:       arguments to pass to the script.
        cwd:         working directory; defaults to the script's own directory.
        python:      if True, run as a Python script via the venv Python.

    Returns:
        tuple of (bool, str): success flag and the combined stdout+stderr output.
        On file‑not‑found, returns (False, "").
    """
    # Build command and label based on script type.
    if python:
        # If python = true, we want the venv Python, the script inside the Python tools directory, correct dependencies
        # So the command becomes: venv-python script arg1 arg2 ...
        script_path = LOCAL_PYTHON_DIR / script_name
        cmd = [str(LOCAL_PYTHON), str(script_path), *args]
        label = f"python3 {script_name} {' '.join(args)}"
    else:
        # If python = false, we want a shell script, run by bash inside the admin folder
        # So the command becomes: bash script arg1 arg2 ...
        script_path = ADMIN_DIR / script_name
        cmd = ["bash", str(script_path), *args]
        label = f"bash {script_name} {' '.join(args)}"

    # Early existence check avoids a cryptic subprocess error (FileNotFoundError: [Errno 2] No such file or directory)
    if not script_path.exists():
        Log.fail(f"Not found: {script_path}")
        return False, ""

    Log.info(f"$ {label}")
    # Many scripts assume relative paths. Running them from the wrong directory breaks them
    # Defaulting to the script’s own folder makes them robust
    # If the caller wants a different working directory, they can override it.
    run_cwd = cwd or script_path.parent

    # Launch the subprocess with stdout and stderr merged.
    # bufsize=1 enables line‑buffered reading, so we can print immediately.
    proc = subprocess.Popen(
        cmd,
        cwd    = str(run_cwd),
        stdout = subprocess.PIPE, # so we can read output line by line
        stderr = subprocess.STDOUT, # merge both streams for unified logging
        text   = True, # decode bytes into strings
        bufsize = 1, # line‑buffered mode (real‑time output)
    )

    # Streaming output in real time
    lines: list[str] = []
    # We know stdout is a pipe because we set stdout=PIPE.
    # This loop reads each line immediately
    assert proc.stdout
    for raw in proc.stdout:
        # Remove (strip) trailing newline for cleaner output.
        line = raw.rstrip()
        # Skip empty lines to avoid clutter.
        if line.strip():
            Log.detail(line)
        lines.append(line)

    proc.wait()
    # Return exit code 0 = success, plus the full combined output (stdout + stderr).
    return proc.returncode == 0, "\n".join(lines)


def run_remote(command: str, label: str = "") -> tuple[bool, str]:
    """
    Run a command on the NixOS server via Fabric/SSH.

    Fabric (paramiko‑based) is used instead of raw subprocess+ssh because
    it handles key negotiation, host key verification, and connection
    management cleanly in pure Python. No need for externally configured
    ~/.ssh/config.

    SSH authentication:
      - If the SSH key file (SSH_KEY) exists, it is passed as key_filename.
      - If the key file does not exist, connect_kwargs is empty; Fabric falls
        back to the system's ssh‑agent or password prompt. This allows
        flexibility without hard‑coding a key path.

    WHY hide="both":
        Fabric normally prints remote stdout/stderr live. We capture it
        ourselves to process and display through Log.detail(), maintaining
        a consistent output style across local and remote steps.

    WHY warn=True:
        By default, Fabric raises an exception if the remote command returns
        a non‑zero exit code. Setting warn=True makes it return the result
        object instead, so we control the error handling and reporting.

    Output streaming:
        The remote command's output is logged only *after* it completes.
        This is fine because the backup and link‑check scripts are
        relatively short‑lived. If a future step needs
        real‑time streaming, we could iterate over the result's stdout
        as it comes in, but the added complexity is not needed now.

    Args:
        command: the shell command line to execute on the remote server.
        label:   optional human‑readable description shown before the command.

    Returns:
        tuple of (bool, str): success flag and combined stdout+stderr output.
    """
    if label:
        Log.info(f"[{SSH_HOST}] {label}")
    Log.info(f"$ {command}")

    # Determine SSH key to use.
    key_path = str(SSH_KEY) if SSH_KEY.exists() else None
    connect_kwargs = {"key_filename": key_path} if key_path else {}

    try:
        conn   = Connection(SSH_HOST, user=SSH_USER, connect_kwargs=connect_kwargs)
        result = conn.run(command, hide="both", warn=True)
        conn.close()

        # Combine stdout and stderr for a full picture.
        combined = result.stdout + result.stderr
        for line in combined.splitlines():
            if line.strip():
                Log.detail(line)

        return result.return_code == 0, combined

    except Exception as e:
        # This catches authentication failures, timeouts, network errors, etc.
        Log.fail(f"SSH error: {e}")
        return False, str(e)

# ══════════════════════════════════════════════════════════════════════════════
# DEPLOYMENT VERIFICATION
# ══════════════════════════════════════════════════════════════════════════════

def find_newest_html(public_dir: Path) -> Path | None:
    """
    Locate the most recently modified non‑empty HTML file in the public directory.

    This function is used after a Hugo deployment to find the very last page that
    was written, which corresponds to the most recent content change. By retrieving
    the file with the highest modification time (`st_mtime`), we can later verify
    that the live server is serving that same content correctly.

    The search is recursive (`rglob("*.html")`) to catch pages in subdirectories.
    Empty files (st_size == 0) are ignored because Hugo may write placeholder files
    that are not real content.

    Returns:
        The Path to the newest non‑empty HTML file, or None if no HTML files exist
        or the public directory is missing.
    """
    if not public_dir.exists():
        return None
    # Sort all .html files descending by modification time.
    candidates = sorted(
        public_dir.rglob("*.html"),
        key=lambda p: p.stat().st_mtime,
        reverse=True,
    )
    # Pick the first that actually has content.
    for f in candidates:
        if f.stat().st_size > 0:
            return f
    return None

def html_path_to_url(html_path: Path, public_dir: Path, base_url: str) -> str:
    """
    Convert a local HTML file path to its corresponding live URL.

    Hugo’s output structure follows conventions:
      - A page named `index.html` is served as its parent directory (trailing slash).
        e.g., `blog/my-post/index.html` → `/blog/my-post/`
      - Any other `.html` file keeps its name.
        e.g., `about.html` → `/about.html`

    The computed relative path is appended to `base_url` (with no double slashes),
    producing a fully qualified URL suitable for HTTP requests.

    Args:
        html_path:   absolute path to the .html file.
        public_dir:  root of the public directory (where the site was generated).
        base_url:    base URL of the live site (e.g., "https://example.com").

    Returns:
        Full URL string.
    """
    rel = html_path.relative_to(public_dir)
    # If the file is an index.html, strip the filename and use a trailing slash.
    url_path = ("/" + str(rel.parent) + "/") if rel.name == "index.html" else ("/" + str(rel))
    # Ensure no accidental double slashes, then join with base URL.
    return base_url.rstrip("/") + url_path.replace("//", "/")

def check_url(url: str, label: str) -> bool:
    """
    Fetch a URL and verify that it returns HTTP 200 with a plausible body length.

    The request uses a custom User‑Agent header so that the server can identify
    the automated checker. A timeout of 15 seconds prevents the pipeline from
    hanging on slow or unreachable hosts.

    The body length threshold (> 100 bytes) helps filter out empty 200 responses
    that some servers return for placeholder pages.

    All network‑related exceptions (HTTP errors, DNS failures, timeouts) are
    caught and logged, so a single failed check does not abort the entire pipeline.

    Args:
        url:   The full URL to check.
        label: Human‑readable description for log messages (e.g., "Homepage").

    Returns:
        True if the URL returned 200 and body > 100 bytes, False otherwise.
    """
    try:
        t0  = time.monotonic()
        req = urllib.request.Request(url, headers={"User-Agent": "weekly-tasks/1.0"})
        with urllib.request.urlopen(req, timeout=15) as resp:
            elapsed_ms = int((time.monotonic() - t0) * 1000)
            body_len   = len(resp.read())
        # Check that the response is positive (resp.status == 200) and meaningful (body_len > 100).
        if resp.status == 200 and body_len > 100:
            Log.ok(f"{label}  HTTP {resp.status}  {body_len:,} bytes  {elapsed_ms}ms")
            return True
        Log.warn(f"{label}  HTTP {resp.status}  body={body_len} bytes")
        return False
    except urllib.error.HTTPError as e:
        Log.fail(f"{label}  HTTP {e.code} {e.reason}"); return False
    except urllib.error.URLError as e:
        Log.fail(f"{label}  {e.reason}"); return False
    except Exception as e:
        Log.fail(f"{label}  {e}"); return False

def verify_deployment(r: StepResult) -> None:
    """
    Check that the live site is serving content after a Hugo deployment.

    Two URLs are verified:
      1. The homepage (base URL + "/"). This guarantees the front page works.
      2. The newest HTML file found in the public directory (by modification time).
         This proves that the most recent content has been published.

    If the homepage check fails, the deployment is considered suspicious,
    but the step is marked as a warning rather than a hard failure, because
    temporary glitches (DNS propagation, Nginx reload timing) can cause
    transient 404/500 errors.

    Args:
        r: StepResult object for the deployment step; its status is updated based on
           the outcome of the verification checks.
    """
    Log.info("Verifying deployment against live site…")
    # Check homepage first.
    all_ok = check_url(LIVE_URL + "/", "Homepage")
    # Identify the newest HTML file produced by Hugo.
    newest = find_newest_html(PUBLIC_DIR)
    if newest:
        live_url = html_path_to_url(newest, PUBLIC_DIR, LIVE_URL)
        mod_time = datetime.fromtimestamp(newest.stat().st_mtime).strftime("%H:%M:%S")
        Log.info(f"Newest: {newest.relative_to(PUBLIC_DIR)}  (modified {mod_time})")
        # Combine result with homepage check.
        all_ok = check_url(live_url, "Newest page") and all_ok
    else:
        Log.warn(f"No HTML found in {PUBLIC_DIR}")
        all_ok = False

    # Final outcome: if all checks passed, mark ok; otherwise warn (non‑fatal).
    if all_ok:   r.ok("Live site verified ✓")
    else:        r.warn("Some URLs did not respond as expected")


# ══════════════════════════════════════════════════════════════════════════════
# PIPELINE STEPS
# ══════════════════════════════════════════════════════════════════════════════

def step_arch_maintain(r: StepResult, dry_run: bool) -> None:
    """
    Run the Arch Linux maintenance script (arch-maintain.sh) with --auto.

    The step supports two execution modes:

      • Dry‑run mode:
          No command is executed. A descriptive log entry is emitted and the
          StepResult is marked as skipped. This allows the full deployment
          pipeline to be rehearsed without touching the system.

      • Normal mode:
          The script is invoked via run_local() with the --auto flag. The wall‑clock
          duration is recorded in the StepResult for later reporting.

    Error handling:
        A non‑zero exit code from the script marks the step as a failure.
        Otherwise, the step is marked successful and the elapsed time is shown.

    Args:
        r:       StepResult for this step; updated with status and duration.
        dry_run: If True, skip execution and mark the step as skipped.
    """
    if dry_run:
        Log.skip("[dry-run] bash arch-maintain.sh --auto"); r.skip("dry-run"); return
    t0 = time.monotonic()
    ok, _ = run_local("arch-maintain.sh", "--auto")
    r.duration = time.monotonic() - t0
    if ok: r.ok(Log.elapsed(r.duration))
    else:  r.fail("Script exited non-zero")


def step_nixos_backup(r: StepResult, dry_run: bool) -> None:
    """
    Run the NixOS backup script on the remote server via SSH.

    Execution model:
        • Normal mode:
            The backup script (mybackup.sh) is invoked remotely using run_remote().
            The command is executed as:
                bash ~/dotfiles/scripts/mybackup.sh
            A non‑zero exit code marks the step as a failure.

        • Dry‑run mode:
            No SSH connection is made and no remote command is executed.
            A descriptive log entry is emitted and the step is marked as skipped.

    Timing:
        The wall‑clock duration of the remote execution is recorded in the
        StepResult for reporting and performance visibility.

    Args:
        r:       StepResult for this step; updated with status and duration.
        dry_run: If True, skip execution and mark the step as skipped.
    """
    if dry_run:
        Log.skip(f"[dry-run] SSH {SSH_HOST} → mybackup.sh"); r.skip("dry-run"); return
    t0 = time.monotonic()
    ok, _ = run_remote("bash ~/dotfiles/scripts/mybackup.sh", "Run mybackup.sh")
    r.duration = time.monotonic() - t0
    if ok: r.ok(Log.elapsed(r.duration))
    else:  r.fail("Remote backup reported errors")


def step_hugo_deploy(r: StepResult, dry_run: bool) -> None:
    """
    Deploy the Hugo site by running deploy.sh, then verify the live site.

    The deployment script is expected to:
      1. Build the site (hugo).
      2. Sync the public/ directory to the production server.

    After a successful deploy, we wait a few seconds for the hosting to propagate
    (e.g., Nginx reload, CDN cache invalidation) and then call verify_deployment()
    to check that key URLs are reachable.

    If deploy.sh itself fails, the step is marked as failed and verification is
    skipped entirely.
    """
    if dry_run:
        Log.skip("[dry-run] bash deploy.sh + verify"); r.skip("dry-run"); return
    t0 = time.monotonic()
    ok, _ = run_local("deploy.sh")
    r.duration = time.monotonic() - t0
    if not ok:
        r.fail(f"deploy.sh failed after {Log.elapsed(r.duration)}"); return
    Log.ok(f"deploy.sh in {Log.elapsed(r.duration)}")
    Log.info("Waiting 5s for hosting to propagate…")
    time.sleep(5)
    verify_deployment(r)


def step_hugo_server(r: StepResult, dry_run: bool) -> None:
    """
    Start (or restart) the Hugo development server via webserver.sh.

    This step is for local purposes only; it is not part of the production
    deployment pipeline but can be triggered manually.

    The script is launched in the background (the caller expects it to
    continue running). We wait `HUGO_WARMUP_SECS` seconds after execution
    to give Hugo time to build the initial pages before marking the step
    as done.

    If the script exits non‑zero, we issue a warning rather than a failure
    because Hugo may still be running; the exit code could reflect a
    non‑critical warning during startup.
    """
    if dry_run:
        Log.skip("[dry-run] bash webserver.sh"); r.skip("dry-run"); return
    t0 = time.monotonic()
    ok, _ = run_local("webserver.sh")
    r.duration = time.monotonic() - t0
    Log.info(f"Waiting {HUGO_WARMUP_SECS}s for Hugo…")
    time.sleep(HUGO_WARMUP_SECS)
    if ok: r.ok(Log.elapsed(r.duration))
    else:  r.warn("webserver.sh exited non-zero (Hugo may still be running)")

def step_link_check(r: StepResult, dry_run: bool) -> None:
    """
    Run link_checker.py locally from the mypython venv.

    WHY locally instead of via SSH:
        The Hugo dev server (started in step 5) runs on THIS machine at
        localhost:1313. Running the link checker locally means it can reach
        http://localhost:1313 directly without any SSH tunnel.

    The link checker is imported directly (not subprocess) because we are
    already running inside the correct venv (ensured at startup by ensure_venv).

    The function `check_hugo_site` (imported from link_checker) is expected to
    return a boolean: True means no broken links, False means problems found.
    Both outcomes are not fatal; the step is marked as "warning" on broken links
    so the pipeline continues.

    Args:
        r:       StepResult for step 5 (link check).
        dry_run: If True, skip execution.
    """
    if dry_run:
        Log.skip("[dry-run] check_hugo_site()"); r.skip("dry-run"); return
    Log.info(f"Running link_checker.py locally from {LOCAL_PYTHON_DIR}...")
    t0 = time.monotonic()
    try:
        success = check_hugo_site()
        r.duration = time.monotonic() - t0
        if success:
            r.ok(f"No broken links  ({Log.elapsed(r.duration)})")
        else:
            # Broken links are a quality issue, not a system failure.
            r.warn(f"Broken links found — check output  ({Log.elapsed(r.duration)})")
    except Exception as e:
        r.fail(str(e))

def step_greetings(r: StepResult, dry_run: bool) -> None:
    """
    Run the daily greetings script (today) from the imported `greetings` module.

    The function `today(config.root_todo)` generates and distributes morning
    greetings based on the todo list and other project configuration.

    This step is light and rarely fails. Any exception is captured and
    recorded as a failure.

    Args:
        r:       StepResult for step 6 (greetings).
        dry_run: If True, skip execution.
    """
    if dry_run:
        Log.skip("[dry-run] today(config.root_todo)"); r.skip("dry-run"); return
    Log.info("Running greetings…")
    t0 = time.monotonic()
    try:
        today(config.root_todo)
        r.duration = time.monotonic() - t0
        r.ok(Log.elapsed(r.duration))
    except Exception as e:
        # If the greetings fail, the pipeline should not abort but must
        # record the error so the user can investigate later.
        r.duration = time.monotonic() - t0
        r.fail(str(e))

# ══════════════════════════════════════════════════════════════════════════════
# SUMMARY TABLE
# ══════════════════════════════════════════════════════════════════════════════

def print_summary(results: list[StepResult], total: float) -> None:
    """
    Print a final table summarising each pipeline step, its status, duration,
    and any notes, along with aggregate counts.

    The table uses colour to highlight statuses (green = ok, red = failed, etc.)
    and displays durations in seconds for quick scanning. Steps that were skipped
    (dry-run or --skip) are shown in cyan.

    At the bottom, a textual summary gives counts and a final conclusion:
      - All ok → celebratory message.
      - Warnings present → warning reminder.
      - Failures present → explicit failure notice.

    Args:
        results: list of StepResult objects in pipeline order.
        total:   total elapsed wall‑clock time for the entire pipeline.
    """
    Log.banner(f"Summary — {Log.elapsed(total)} total")
    # Map each status to its display colour for a compact table.
    colours = {
        "ok": Log.GREEN, "failed": Log.RED,
        "warning": Log.YELLOW, "skipped": Log.CYAN,
    }

    # Table header.
    print(f"  {'#':<4}  {'Status':<10}  {'Time':>6}  Name")
    print(f"  {'─'*4}  {'─'*10}  {'─'*6}  {'─'*35}")

    # Table rows.
    for r in results:
        c   = colours.get(r.status, "")
        dur = f"{r.duration:.0f}s" if r.duration else "—"
        print(f"  {r.number:<4}  {c}{r.status:<10}{Log.RESET}  {dur:>6}  {r.name}"
              + (f"  {Log.DIM}{r.notes}{Log.RESET}" if r.notes else ""))

    # Count per status for aggregation.
    counts = {s: sum(1 for r in results if r.status == s)
              for s in ("ok", "failed", "warning", "skipped")}
    print()
    print(f"  {Log.GREEN}{counts['ok']} ok{Log.RESET}  "
          f"{Log.RED}{counts['failed']} failed{Log.RESET}  "
          f"{Log.YELLOW}{counts['warning']} warnings{Log.RESET}  "
          f"{Log.CYAN}{counts['skipped']} skipped{Log.RESET}")
    print()

    # Final verdict: prioritise failures, then warnings, then success.
    if counts["failed"]:   Log.fail("Some steps failed.")
    elif counts["warning"]: Log.warn("Some steps had warnings.")
    else:                   Log.ok("All steps completed 🎉")


# ══════════════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════════════

PIPELINE = [
    (1, "Arch maintenance",     step_arch_maintain),
    (2, "NixOS backup (SSH)",   step_nixos_backup),
    (3, "Hugo deploy + verify", step_hugo_deploy),
    (4, "Hugo server (local)",  step_hugo_server),
    (5, "Link checker (local)", step_link_check),
    (6, "Greetings",            step_greetings),
]


def parse_args() -> argparse.Namespace:
    """
    Parse command‑line arguments.

    Options:
      --dry-run, -n  : simulate everything; no changes made.
      --skip N,N     : comma‑separated list of step numbers to skip.
      --from N       : start from step N (skip earlier steps).
    """
    p = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--dry-run", "-n", action="store_true")
    p.add_argument("--skip",    metavar="N,N", default="")
    p.add_argument("--from",    dest="from_step", metavar="N", type=int, default=1)
    return p.parse_args()


def main() -> None:

    # ── Ensure we are running in the project venv ─────────────────────────
    ensure_venv()

    args    = parse_args()
    dry_run = args.dry_run

    skip: set[int] = set()
    if args.skip:
        try:
            skip = {int(x.strip()) for x in args.skip.split(",") if x.strip()}
        except ValueError:
            Log.fail("--skip must be comma-separated integers"); sys.exit(1)

    Log.banner(f"Weekly Pipeline — {datetime.now().strftime('%Y-%m-%d  %H:%M')}")
    Log.info(f"Python: {sys.executable}")
    Log.info(f"Venv:   {'yes ✅' if is_in_venv() else 'no ⚠️ '}")
    if dry_run:
        Log.warn("DRY RUN — nothing will be executed")

    results: list[StepResult] = []
    t_start = time.monotonic()

    for number, name, fn in PIPELINE:
        if number < args.from_step:
            r = StepResult(number, name); r.skip(f"before --from {args.from_step}")
            results.append(r); continue

        Log.step(number, name)
        r = StepResult(number, name)
        results.append(r)

        if number in skip:
            Log.skip(f"Step {number} skipped via --skip")
            r.skip("--skip flag"); continue

        try:
            fn(r, dry_run)
        except Exception:
            r.fail("Unhandled exception"); traceback.print_exc()

    print_summary(results, time.monotonic() - t_start)
    if any(r.status == "failed" for r in results):
        sys.exit(1)


if __name__ == "__main__":
    main()
Bitcoin donation

JustToThePoint Copyright © 2011 - 2026 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.