AUTHOR
FIREWHEEL Team
DONE
DESCRIPTION
Push a file to a VM using the VM resource handler. This does not require that
the VM is running an SSH server. Also, unlike :ref:`helper_scp`, there is no need
to use the :ref:`control_network_mc` model component since the VM resource handler has access to
the VM through a serial port.

All files get placed at the location specified on the command line.

.. note::

    The destination **MUST** be the full path (including the filename), not simply a destination directory.

.. warning::

    Any shell expansions (e.g. ``~``) used in the ``destination`` path are resolved **BEFORE** the file is pushed to the VM.

This Helper supports both human-readable text output and machine-readable
JSON output.

**Usage:**  ``firewheel push file [-h] [--format {text,json}] <filename> <vm hostname> <destination>``

Arguments
+++++++++

Named Arguments
^^^^^^^^^^^^^^^

.. option:: -h, --help

    Show this help message and exit.

.. option:: --format {text,json}

    :default: ``text``

    Select the output format. ``text`` prints a human-readable scheduling summary.
    ``json`` returns a structured JSON response.

Positional Arguments
^^^^^^^^^^^^^^^^^^^^

.. option:: <filename>

    The name of the file to push to the VM.

.. option:: <vm hostname>

    The hostname of the VM to push the file to.

.. option:: <destination>

    The full path (including the filename) of the destination location on the VM for the file.

Example
+++++++

``firewheel push file /tmp/test.txt host.root.net /tmp/myfile.txt``

``firewheel push file /tmp/test.txt host.windows.net '/Users/User/Downloads/myfile.txt'``

``firewheel push file --format json /tmp/test.txt host.root.net /tmp/myfile.txt``

DONE
RUN LocalPython ON control
#!/usr/bin/env python

import sys
import json
import pickle
import argparse
from pathlib import Path, PureWindowsPath
from datetime import datetime, timezone

from rich.console import Console

import firewheel.vm_resource_manager.api as vrm_api
from firewheel.lib.minimega.api import minimegaAPI
from firewheel.vm_resource_manager.schedule_db import ScheduleDb
from firewheel.vm_resource_manager.schedule_entry import ScheduleEntry


class PushFileError(Exception):
    """Expected operational error for the push file helper."""

    def __init__(self, message, error_type):
        super().__init__(message)
        self.message = message
        self.error_type = error_type


def build_error_result(filename, vm_name, destination, message, error_type):
    """
    Build a structured error result.

    Args:
        filename (str): Local source path.
        vm_name (str): VM hostname.
        destination (str): Destination path on VM.
        message (str): Human-readable error message.
        error_type (str): Machine-readable error type.

    Returns:
        dict: Structured error result.
    """
    source_path = Path(filename)

    return {
        "success": False,
        "vm_name": vm_name,
        "host_found": False,
        "windows_vm": False,
        "source": str(source_path),
        "source_exists": source_path.exists(),
        "source_basename": source_path.name,
        "destination": destination,
        "resource_added": False,
        "schedule_entry_added": False,
        "message": message,
        "error": {
            "type": error_type,
            "detail": message,
        },
        "generated_at": datetime.now(timezone.utc).isoformat(),
    }


def schedule_file_transfer(name, file_name, destination, windows):
    """
    Schedule a VM resource file transfer.

    This function retrieves the schedule for a specified virtual machine (VM)
    from the schedule database, adds a new schedule entry for transferring a
    resource file to the VM, and updates the schedule in the database. It
    supports both Unix-like and Windows file transfer commands.

    Args:
        name (str): VM name.
        file_name (str): Source file path on the local system.
        destination (str): Destination path on the VM.
        windows (bool): Whether the destination VM is a Windows VM.

    Returns:
        dict: Structured result data for the scheduled file transfer.

    Raises:
        PushFileError: For expected operational failures.
    """
    schedule_db = ScheduleDb()

    try:
        pickled_schedule = schedule_db.get(name)
    except OSError as exc:
        raise PushFileError(
            f"Unable to access schedule database for VM '{name}': {exc}",
            "schedule_db_error",
        ) from exc

    if not pickled_schedule:
        raise PushFileError(
            f"Unable to get schedule for VM: {name}",
            "schedule_unavailable",
        )

    try:
        schedule = pickle.loads(pickled_schedule)
    except (pickle.PickleError, pickle.UnpicklingError, EOFError, ValueError, TypeError) as exc:
        raise PushFileError(
            f"Unable to decode schedule for VM '{name}': {exc}",
            "schedule_decode_error",
        ) from exc

    try:
        vrm_api.add_vm_resource_file(file_name)
    except OSError as exc:
        raise PushFileError(
            f"Unable to add VM resource file '{file_name}': {exc}",
            "vm_resource_add_error",
        ) from exc

    source_basename = Path(file_name).name

    se = ScheduleEntry(-100000, ignore_failure=True)
    if not windows:
        se.set_executable("mv", f"{source_basename} {destination}")
        rendered_destination = str(Path(destination))
        transfer_command = "mv"
    else:
        se.set_executable("move", f"{source_basename} {destination}")
        rendered_destination = str(PureWindowsPath(destination))
        transfer_command = "move"

    se.add_file(source_basename, source_basename, False)
    schedule.append(se)

    try:
        schedule_db.put(name, pickle.dumps(schedule), None)
    except (pickle.PickleError, OSError, TypeError, ValueError) as exc:
        raise PushFileError(
            f"Unable to store updated schedule for VM '{name}': {exc}",
            "schedule_store_error",
        ) from exc

    return {
        "resource_added": True,
        "schedule_entry_added": True,
        "source_basename": source_basename,
        "rendered_destination": rendered_destination,
        "transfer_command": transfer_command,
    }


def collect_push_file_result(filename, vm_name, destination):
    """
    Execute validation and scheduling for push file.

    Args:
        filename (str): Local source path.
        vm_name (str): VM hostname.
        destination (str): Destination path on VM.

    Returns:
        dict: Structured result information.

    Raises:
        PushFileError: For expected operational failures.
    """
    source_path = Path(filename)
    if not source_path.exists():
        raise PushFileError(
            f"The file being pushed to the VM '{source_path}' does not exist!",
            "file_not_found",
        )

    mm_api = minimegaAPI()
    mm_dict = mm_api.mm_vms()

    if vm_name not in mm_dict:
        raise PushFileError(
            f"No VM '{vm_name}' found.",
            "vm_not_found",
        )

    vm_info = mm_dict[vm_name]
    windows_vm = "windows" in vm_info["image"]

    result = {
        "success": False,
        "vm_name": vm_name,
        "host_found": True,
        "windows_vm": windows_vm,
        "source": str(source_path),
        "source_exists": True,
        "source_basename": source_path.name,
        "destination": destination,
        "resource_added": False,
        "schedule_entry_added": False,
        "message": "",
        "error": None,
        "generated_at": datetime.now(timezone.utc).isoformat(),
    }

    schedule_result = schedule_file_transfer(
        vm_name,
        str(source_path),
        destination,
        windows_vm,
    )
    result.update(schedule_result)

    result["success"] = True
    result["message"] = (
        "Successfully added a new schedule entry. "
        "Pending any errors, the file will be added shortly. "
        "To investigate the progress, visit the VM's resource log file."
    )

    return result


def render_text(result):
    """
    Render human-readable output using Rich.
    """
    console = Console()

    if not result["success"]:
        console.print("[bold red]Push failed[/bold red]")
        console.print(f"[red]{result['message']}[/red]")
        console.print(f"[cyan]Source:[/cyan] {result['source']}")
        console.print(f"[cyan]VM:[/cyan] {result['vm_name']}")
        console.print(f"[cyan]Destination:[/cyan] {result['destination']}")
        return

    console.print("[bold green]Push scheduled successfully[/bold green]")
    console.print(f"[cyan]Source:[/cyan] {result['source']}")
    console.print(f"[cyan]VM:[/cyan] {result['vm_name']}")
    console.print(f"[cyan]Destination:[/cyan] {result['rendered_destination']}")
    console.print(f"[cyan]Transfer command:[/cyan] {result['transfer_command']}")
    console.print(f"\n[green]{result['message']}[/green]")


def render_json(result):
    """
    Render machine-readable JSON output.
    """
    print(json.dumps(result, indent=2))


def main():
    parser = argparse.ArgumentParser(
        description="Push a file to a VM using the VM resource handler.",
        prog="firewheel push file",
    )

    parser.add_argument(
        "--format",
        choices=["text", "json"],
        default="text",
        help="Output format. Defaults to text.",
    )

    parser.add_argument(
        "filename",
        help="The path to the file to push to the VM.",
    )

    parser.add_argument(
        "vm_name",
        help="The hostname of the VM to push the file to.",
    )

    parser.add_argument(
        "destination",
        help="The full path (including the filename) of the destination "
             "location on the VM for the file.",
    )

    args = parser.parse_args()

    try:
        result = collect_push_file_result(args.filename, args.vm_name, args.destination)
    except PushFileError as exc:
        result = build_error_result(
            args.filename,
            args.vm_name,
            args.destination,
            exc.message,
            exc.error_type,
        )

    if args.format == "json":
        render_json(result)
    else:
        render_text(result)

    sys.exit(0 if result["success"] else 1)


if __name__ == "__main__":
    main()
DONE
