Public/FileTransfer/Copy-VmFiles.ps1

<#
.SYNOPSIS
    Copies host files to a Hyper-V VM via the host file server and SSH.
 
.DESCRIPTION
    For each entry in -Entries, stages the host file via Add-VmFileServerFile
    and runs (under sudo on the VM): mkdir -p of the parent target dir, curl
    -fsSL -o of the staged URL, then chown / chmod to the requested values.
 
    This is a pure transport primitive. It has no opinion on where the
    entries came from or what they semantically represent - the caller
    owns the schema and any policy around what is allowed in their
    context.
 
    Each entry is processed in its own SSH round-trip so a per-entry error
    message can name both source and target. Errors abort - downstream
    entries are not attempted, mirroring the behaviour of "set -e" in a
    shell script.
 
.PARAMETER SshClient
    A live Renci.SshNet.SshClient. The caller owns the client's lifecycle.
    Connecting and disposing is NOT the responsibility of this function.
 
.PARAMETER Server
    A file-server handle returned by Start-VmFileServer or supplied by
    Invoke-WithVmFileServer's scriptblock. The handle's BaseUrl is used
    by Add-VmFileServerFile to construct the per-entry URL.
 
.PARAMETER Entries
    An array of entry descriptors. Each entry MUST expose:
      - Source : a host path that already exists.
      - Target : an absolute Linux path on the VM.
    Each entry MAY expose:
      - Owner : a chown argument string. Defaults to 'root:root'. Pass
                 'user' or 'user:group' as you would to chown directly.
      - Mode : a chmod argument string. Defaults to '0644'.
 
    Hashtables and PSCustomObjects both work. Validation of these fields
    against any caller-specific schema is the caller's responsibility;
    Assert-VmFilesField in this module provides the shared shape checks.
 
.EXAMPLE
    Invoke-WithVmFileServer -VmIpAddress '10.10.0.50' -ScriptBlock {
        param($server)
        $sshClient = New-VmSshClient -IpAddress '10.10.0.50' -Username 'admin' -Password 'secret'
        try {
            Copy-VmFiles -SshClient $sshClient -Server $server -Entries @(
                @{ Source = 'C:\jars\foo.jar'; Target = '/opt/lib/foo.jar' },
                @{ Source = 'C:\seed.json'; Target = '/var/data/seed.json'; Owner = 'app'; Mode = '0640' }
            )
        }
        finally {
            if ($sshClient.IsConnected) { $sshClient.Disconnect() }
            $sshClient.Dispose()
        }
    }
 
.NOTES
    All on-VM commands are issued under sudo so the function can satisfy
    any combination of Owner/Mode regardless of which user the SSH client
    authenticated as. The caller is responsible for ensuring that user
    has password-less sudo (cloud-init's default admin user does).
#>

function Copy-VmFiles {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $SshClient,

        [Parameter(Mandatory)]
        [object] $Server,

        [Parameter(Mandatory)]
        [object[]] $Entries
    )

    foreach ($entry in $Entries) {
        $source = $entry.Source
        $target = $entry.Target
        # Defaults match the system-level read-only file shape. Callers
        # override per-entry by setting Owner / Mode on the entry object.
        $owner  = if ($entry.PSObject.Properties['Owner'] -and $entry.Owner) { $entry.Owner } else { 'root:root' }
        $mode   = if ($entry.PSObject.Properties['Mode']  -and $entry.Mode)  { $entry.Mode  } else { '0644' }

        $url = Add-VmFileServerFile -Server $Server -LocalPath $source

        # PS-side $target / $url / $owner / $mode interpolate at construction;
        # backtick-prefixed shell variables stay literal so the running shell
        # dereferences its own copies. mkdir -p is a no-op when the parent
        # already exists; curl -fsSL follows redirects, fails on HTTP errors,
        # stays silent on success.
        $script = @"
set -e
target='$target'
url='$url'
owner='$owner'
mode='$mode'
sudo mkdir -p "`$(dirname "`$target")"
sudo curl -fsSL -o "`$target" "`$url"
sudo chown "`$owner" "`$target"
sudo chmod "`$mode" "`$target"
"@


        # Windows PowerShell here-strings use CRLF; remote bash interprets
        # the trailing \r as part of the token (e.g. "set -e\r" -> invalid
        # option "-", "root:root\r" -> invalid group). Normalise to LF.
        $script = $script -replace "`r`n", "`n"

        $result = Invoke-SshClientCommand -SshClient $SshClient -Command $script
        if ($result.ExitStatus -ne 0) {
            throw ("Copy-VmFiles failed (source: $source, target: $target, " +
                "exit $($result.ExitStatus)). " +
                "stdout: $($result.Output) stderr: $($result.Error)")
        }
    }
}