Public/ProfileD/Set-VmProfileDScript.ps1

<#
.SYNOPSIS
    Writes a /etc/profile.d/<Name>.sh script on a Hyper-V VM under sudo,
    atomically and (by default) only when the existing file does not
    already match.
 
.DESCRIPTION
    Single-round-trip primitive that drops a profile.d shell snippet on
    the VM. The atomic-write tail (temp file + chown + chmod + mv) is
    generated by the shared New-AtomicWriteBashFragment helper so the
    pattern has one source of truth across this module's install
    primitives.
 
    Skip-unchanged (default) byte-compares the desired content against
    the file currently on the VM and exits before touching anything if
    they match. -NoSkipUnchanged forces the write path (mtime advances)
    and is the recovery switch for callers that suspect out-of-band
    tampering or want the mtime as a signal.
 
    Validation is host-side and runs before any SSH call:
      - Name is non-empty, matches ^[A-Za-z0-9._-]+$, and does not end in
        '.sh' (the cmdlet appends it - accepting the suffix would let
        callers double-suffix the on-VM path).
      - Name is not '.' / '..' (heredoc body would point at a directory).
    A trailing newline is appended to Content if missing: a profile.d
    snippet without one is silently ignored by some POSIX shells when
    /etc/profile sources the directory in a loop. The byte-equality
    short-circuit compares the normalised desired content against the
    file as cat sees it, so the normalisation is observable both in the
    emitted script and in the long-term file state.
 
.PARAMETER SshClient
    A live Renci.SshNet.SshClient. The caller owns the client's
    lifecycle - this function neither connects nor disposes it.
 
.PARAMETER Name
    Base name of the profile.d script. The cmdlet writes
    /etc/profile.d/<Name>.sh; do not include the .sh suffix in this
    parameter.
 
.PARAMETER Content
    Full text of the script. May be empty; the cmdlet appends a
    trailing newline if missing. Embedded ' " $ and backslashes are
    preserved byte-for-byte (the on-wire transport is a single-quoted
    heredoc, so no shell expansion runs over the value).
 
.PARAMETER NoSkipUnchanged
    Forces the always-write path. Off by default - the skip-unchanged
    branch produces identical observable state at lower cost. Use this
    switch when the file mtime itself matters or when recovering from
    drift you specifically want to overwrite.
 
.EXAMPLE
    Set-VmProfileDScript -SshClient $ssh -Name 'foo' -Content "export FOO=1`n"
 
.NOTES
    On-VM commands run under sudo. The caller is responsible for
    ensuring the SSH user has password-less sudo (cloud-init's default
    admin user does).
#>

function Set-VmProfileDScript {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $SshClient,

        [Parameter(Mandatory)]
        [string] $Name,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Content,

        [switch] $NoSkipUnchanged
    )

    # Name validation. Shared with Remove-VmProfileDScript via the
    # private helper so an install accepted by one cmdlet cannot be
    # rejected by the other. Restricted to a tight character class so
    # the value can be embedded into a single-quoted bash assignment
    # and into a path with no risk of metacharacter interpretation.
    Assert-VmProfileDScriptName -Name $Name -CmdletName 'Set-VmProfileDScript'

    $targetPath = "/etc/profile.d/$Name.sh"

    # Profile.d snippets without a trailing newline are silently ignored
    # by some POSIX shells when /etc/profile sources the directory in a
    # for-loop. Normalise here so the property holds regardless of how
    # the caller composed Content.
    $desiredContent = if ($Content.EndsWith("`n")) { $Content } else { "$Content`n" }

    $vmHost = if ($SshClient.PSObject.Properties['ConnectionInfo'] -and $SshClient.ConnectionInfo) {
        $SshClient.ConnectionInfo.Host
    } else { '(unknown)' }

    # The reconcile branch is what makes the cmdlet a "skip-unchanged"
    # primitive. -NoSkipUnchanged omits it entirely so the script
    # always writes - matches Set-VmEnvironmentVariables / Copy-VmFiles.
    # Note that both EXISTING and DESIRED pass through bash command
    # substitution ($(...)), which strips trailing newlines, so the
    # comparison is normalised-content vs normalised-content.
    $reconcileBlock = if ($NoSkipUnchanged) { '' } else {
@'
 
if [ -f "$TARGET" ]; then
    EXISTING=$(sudo cat "$TARGET")
else
    EXISTING=""
fi
if [ "$EXISTING" = "$DESIRED" ]; then
    exit 0
fi
'@

    }

    # The atomic-write tail (temp file + chown + chmod + mv) is shared
    # with the other VM-install primitives. New-AtomicWriteBashFragment
    # owns the single source of truth for that pattern; this cmdlet
    # composes it with a DESIRED bash variable defined immediately
    # above via a single-quoted heredoc (so the content's ' " $ \
    # survive verbatim).
    $atomicWrite = New-AtomicWriteBashFragment `
        -TargetPath $targetPath `
        -ContentVar 'DESIRED'

    # Heredoc delimiter is namespaced + uppercase so a profile.d line
    # (whose name is restricted by the validator above) cannot
    # collide with it and prematurely close the heredoc.
    $script = @"
set -euo pipefail
TARGET='$targetPath'
DESIRED=`$(cat <<'__INFRA_HYPERV_PROFILED_CONTENT__'
$desiredContent
__INFRA_HYPERV_PROFILED_CONTENT__
)
$reconcileBlock
$atomicWrite
"@


    # Windows PowerShell here-strings use CRLF; remote bash interprets
    # the trailing \r as part of the token. Normalise to LF, same as
    # the rest of the module.
    $script = $script -replace "`r`n", "`n"

    $result = Invoke-SshClientCommand -SshClient $SshClient -Command $script
    if ($result.ExitStatus -ne 0) {
        throw ("Set-VmProfileDScript failed (vm: $vmHost, name: $Name, " +
            "exit $($result.ExitStatus)). stdout: $($result.Output) " +
            "stderr: $($result.Error)")
    }
}