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)") } } |