Public/EnvVars/Set-VmEnvironmentVariables.ps1

<#
.SYNOPSIS
    Writes a managed block of system-wide environment variables to
    /etc/environment on a Hyper-V VM over SSH.
 
.DESCRIPTION
    Reconciles the contents of a sentinel-delimited "managed block"
    inside /etc/environment with the desired set of entries:
 
      # BEGIN <BlockName>
      NAME1="VAL1"
      NAME2="VAL2"
      # END <BlockName>
 
    The BlockName is supplied per call so two consumers wiring this
    transport into the same VM can coexist in one /etc/environment
    under their own independent blocks - a single shared sentinel
    would let the last writer wipe every other consumer's keys.
 
    Lines outside the managed block (Ubuntu's default PATH=..., any
    operator additions, other consumers' blocks) are preserved
    byte-for-byte. By default the function skips the write entirely
    when the existing block already matches the desired block; pass
    -NoSkipUnchanged to force a write even on a match (useful when
    recovering from out-of-band tampering or when the file's mtime
    is itself meaningful).
 
    The whole reconcile + strip + append + atomic-write sequence is one
    SSH round-trip, mirroring the discipline used by Copy-VmFiles. The
    write goes via a temp file in /etc plus mv, so /etc/environment is
    either the old version or the new version at every observable
    moment - never a partial write.
 
    Entries are validated against Assert-VmEnvVarsField before any SSH
    call is made (single source of truth for the schema), so malformed
    input fails on the host without touching the wire. An empty entries
    array is a valid intent meaning "remove the managed block"; lines
    outside the block are still preserved.
 
.PARAMETER SshClient
    A live Renci.SshNet.SshClient. The caller owns the client's
    lifecycle - Set-VmEnvironmentVariables neither connects nor
    disposes it.
 
.PARAMETER Entries
    Array of { name, value } entries (PSCustomObjects, as
    ConvertFrom-Json produces). Empty array is allowed and means
    "remove the managed block". See Assert-VmEnvVarsField for the
    exact rules; this function calls that validator before sending
    anything to the VM.
 
.PARAMETER BlockName
    The name embedded in the BEGIN / END sentinel markers. Lets
    multiple unrelated consumers maintain their own managed blocks
    inside the same /etc/environment without colliding. Same rules
    as the JSON-side blockName (see Assert-VmEnvVarsField); the
    transport re-validates host-side so direct callers that bypass
    the JSON validator cannot smuggle ' / newline / NUL into the
    marker assignment.
 
.PARAMETER NoSkipUnchanged
    Forces the always-write path. Off by default - the skip-unchanged
    path produces the same observable state at lower cost. Use this
    switch when the file mtime itself matters or when recovering from
    drift inside the managed block whose detection the caller wants to
    bypass.
 
.EXAMPLE
    Set-VmEnvironmentVariables -SshClient $ssh -BlockName 'ci-agent' -Entries @(
        [PSCustomObject]@{ name = 'FOO_HOME'; value = '/opt/foo' },
        [PSCustomObject]@{ name = 'BAR_OPTS'; value = '-Xmx512m' }
    )
 
.NOTES
    All on-VM commands run under sudo so the function can write
    /etc/environment 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 Set-VmEnvironmentVariables {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $SshClient,

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

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

        [switch] $NoSkipUnchanged
    )

    # Re-validate BlockName host-side using the same rules as
    # Assert-VmEnvVarsField. Direct callers may legitimately bypass
    # the JSON validator (e.g. tests, ad-hoc scripts) but no caller
    # may inject characters that would break out of the marker's
    # single-quoted bash assignment.
    $blockNameRegex = '^[A-Za-z0-9._ -]+$'
    if ([string]::IsNullOrEmpty($BlockName)) {
        throw "Set-VmEnvironmentVariables: -BlockName must be a non-empty string."
    }
    if ($BlockName.Length -gt 128) {
        throw "Set-VmEnvironmentVariables: -BlockName length $($BlockName.Length) exceeds the 128-char limit."
    }
    if ($BlockName -notmatch $blockNameRegex) {
        throw "Set-VmEnvironmentVariables: -BlockName '$BlockName' contains a disallowed character (allowed: $blockNameRegex)."
    }
    if ($BlockName.Trim() -ne $BlockName) {
        throw "Set-VmEnvironmentVariables: -BlockName '$BlockName' must not start or end with whitespace."
    }

    # Validate via the shared schema rule set so the regex / duplicate
    # checks have a single source of truth. Skip the synthetic wrap on
    # the empty-array branch because "remove the block" is a valid
    # intent with no entries to validate.
    if ($Entries.Count -gt 0) {
        Assert-VmEnvVarsField -Vm ([PSCustomObject]@{
            envVars = [PSCustomObject]@{
                blockName = $BlockName
                entries   = $Entries
            }
        })
    }

    # Build the desired block CONTENT (no markers). The markers live
    # on the VM side as bash variables so the skip-unchanged compare
    # operates on content only - any future change to marker text
    # would otherwise re-write every existing VM once just to migrate
    # the wrapper.
    $contentLines = foreach ($entry in $Entries) {
        # Escape backslash first, then double-quote. Order matters: a
        # later backslash escape would re-escape the backslashes we
        # just emitted to escape the quotes. pam_env / bash both
        # parse "..." with these two escapes.
        $escaped = $entry.value.Replace('\', '\\').Replace('"', '\"')
        "$($entry.name)=`"$escaped`""
    }
    $desiredBlock = if ($contentLines) { ($contentLines -join "`n") } else { '' }

    $vmHost = if ($SshClient.PSObject.Properties['ConnectionInfo'] -and $SshClient.ConnectionInfo) {
        $SshClient.ConnectionInfo.Host
    } else { '(unknown)' }
    $namesList = if ($Entries.Count -gt 0) { (($Entries | ForEach-Object { $_.name }) -join ', ') } else { '(none)' }

    # The reconcile block (block-extract + byte-equality + early
    # exit 0) is the whole point of skip-unchanged. -NoSkipUnchanged
    # omits it entirely so the script always writes - matches the
    # shape Copy-VmFiles uses for the same switch.
    $reconcileBlock = if ($NoSkipUnchanged) { '' } else {
@'
 
EXISTING=$(printf '%s\n' "$CURRENT" | awk -v b="$BEGIN_MARKER" -v e="$END_MARKER" '$0==b{f=1;next} $0==e{f=0;next} f')
if [ "$EXISTING" = "$DESIRED" ]; then
    exit 0
fi
'@

    }

    # Heredoc delimiter is namespaced + uppercase so a NAME="VALUE"
    # line - whose name is restricted to POSIX identifiers by the
    # validator - can never collide with it and prematurely close
    # the heredoc.
    $script = @"
set -euo pipefail
TARGET=/etc/environment
BEGIN_MARKER='# BEGIN $BlockName'
END_MARKER='# END $BlockName'
DESIRED=`$(cat <<'__INFRA_HYPERV_DESIRED_BLOCK__'
$desiredBlock
__INFRA_HYPERV_DESIRED_BLOCK__
)
if [ -f "`$TARGET" ]; then
    CURRENT=`$(sudo cat "`$TARGET")
else
    CURRENT=""
fi
$reconcileBlock
STRIPPED=`$(printf '%s\n' "`$CURRENT" | awk -v b="`$BEGIN_MARKER" -v e="`$END_MARKER" 'BEGIN{f=0} f==0 && `$0==b{f=1;next} f==1 && `$0==e{f=0;next} f==0')
TMP="/etc/environment.tmp.`$`$"
if [ -n "`$DESIRED" ]; then
    printf '%s\n%s\n%s\n%s\n' "`$STRIPPED" "`$BEGIN_MARKER" "`$DESIRED" "`$END_MARKER" | sudo tee "`$TMP" >/dev/null
else
    printf '%s\n' "`$STRIPPED" | sudo tee "`$TMP" >/dev/null
fi
sudo chown root:root "`$TMP"
sudo chmod 0644 "`$TMP"
sudo mv "`$TMP" "`$TARGET"
"@


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

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