add upload feature to DSA manifest creation

This commit is contained in:
2026-09-30 08:12:55 -04:00
parent c74fe82d36
commit 35c1904d0d
3 changed files with 175 additions and 16 deletions
+40 -13
View File
@@ -2,15 +2,18 @@
<#
.SYNOPSIS
Downloads and runs the DSA manifest collector.
Downloads and runs the DSA manifest collector, with optional upload and cleanup.
.DESCRIPTION
Tools.ps1 must be dot-sourced by the caller first. Set $DSAManifestSource
in the caller's scope before using Run-Script -LivePSScript Get-DSAManifest.
The output path is generated in $Global:OutputDirectory with the filename
dsa-manifest-HOSTNAME-yyyyMMddHHmmss.csv, using the computer name and current local date and time.
Set $Zip to 'true', '$true', 'yes', 'y', or '1' to include --zip.
Use 'false', '$false', 'no', 'n', or '0' to disable ZIP; an empty value also disables it.
Values are case-insensitive and surrounding whitespace is ignored.
$Zip enables ZIP compression; $Upload sends the result to the FileDrop;
$DeleteAfterUpload removes the local result only after a confirmed upload.
All three accept 'true', '$true', 'yes', 'y', or '1' to enable, and
'false', '$false', 'no', 'n', or '0' to disable. Empty values disable the option.
Values are case-insensitive and surrounding whitespace is ignored. When
$Upload is false, the local CSV or ZIP is retained regardless of $DeleteAfterUpload.
#>
If ( [string]::IsNullOrWhiteSpace([string]$DSAManifestSource) ) {
@@ -18,16 +21,20 @@ If ( [string]::IsNullOrWhiteSpace([string]$DSAManifestSource) ) {
}
# Normalize RMM text values without treating every nonempty string as true.
$DsaZipValue = ([string]$Zip).Trim().ToLowerInvariant()
If ( $DsaZipValue -in @('true', '$true', 'yes', 'y', '1') ) {
$DsaZipEnabled = $true
}
ElseIf ( $DsaZipValue -in @('', 'false', '$false', 'no', 'n', '0') ) {
$DsaZipEnabled = $false
}
Else {
Throw 'Invalid $Zip value. Use true, $true, yes, y, 1, false, $false, no, n, or 0.'
$DsaOptions = @{ Zip = $Zip; Upload = $Upload; DeleteAfterUpload = $DeleteAfterUpload }
ForEach ( $DsaOptionName in @('Zip', 'Upload', 'DeleteAfterUpload') ) {
$DsaOptionValue = ([string]$DsaOptions[$DsaOptionName]).Trim().ToLowerInvariant()
If ( $DsaOptionValue -in @('true', '$true', 'yes', 'y', '1') ) {
$DsaOptions[$DsaOptionName] = $true
}
ElseIf ( $DsaOptionValue -in @('', 'false', '$false', 'no', 'n', '0') ) {
$DsaOptions[$DsaOptionName] = $false
}
Else {
Throw ('Invalid ${0} value. Use true, $true, yes, y, 1, false, $false, no, n, or 0.' -f $DsaOptionName)
}
}
$DsaZipEnabled = $DsaOptions.Zip
$DSAManifestOutput = Join-Path -Path $Global:OutputDirectory -ChildPath (
'dsa-manifest-{0}-{1}.csv' -f $Env:COMPUTERNAME, (Get-Date -Format 'yyyyMMddHHmmss')
@@ -65,6 +72,7 @@ $DsaCommandLine = ($DsaArguments | ForEach-Object {
'"{0}"' -f ($_ -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1')
}) -join ' '
$DsaCollectionTime = Get-Date
Write-Output "Running dsa-collect.exe (ZIP compression: ${DsaZipEnabled})"
$DsaProcess = Start-Process -FilePath $DsaExecutablePath -ArgumentList $DsaCommandLine `
-Wait -PassThru -WindowStyle Hidden -ErrorAction Stop
@@ -80,3 +88,22 @@ If ( !(Test-Path -LiteralPath $DsaResultPath -PathType Leaf) ) {
Throw "dsa-collect.exe did not create the expected output: ${DsaResultPath}"
}
Write-Output "DSA manifest created: ${DsaResultPath}"
If ( $DsaOptions.Upload ) {
Write-Output "Uploading DSA manifest: ${DsaResultPath}"
$DsaUploadReceipt = Upload-File -Path $DsaResultPath `
-Subject "DSA manifest file uploaded from ${Env:COMPUTERNAME}" `
-Message "DSA collection performed on ${DSAManifestSource} at $($DsaCollectionTime.ToString('yyyy-MM-dd HH:mm:ss zzz'))" `
-ErrorAction Stop
If ( [string]::IsNullOrWhiteSpace([string]$DsaUploadReceipt.AttachmentId) -or
[string]::IsNullOrWhiteSpace([string]$DsaUploadReceipt.Status) ) {
Throw 'The upload did not return a confirmation receipt. The local manifest has been retained.'
}
Write-Output "DSA manifest uploaded: ${DsaResultPath}"
If ( $DsaOptions.DeleteAfterUpload ) {
Remove-Item -LiteralPath $DsaResultPath -Force -ErrorAction Stop
Write-Output "Deleted uploaded DSA manifest: ${DsaResultPath}"
}
$DsaUploadReceipt
}
+3 -3
View File
@@ -14,11 +14,10 @@ After dot-sourcing `Tools.ps1`, scripts can upload a completed file to the Ember
```powershell
Upload-File -Path 'C:\ProgramData\Emberkom\Output\report.zip'
# Inside Get-DSAManifest.ps1, after successful collection:
Upload-File -Path $DsaResultPath -Subject "DSA manifest from ${Env:COMPUTERNAME}"
```
`Get-DSAManifest.ps1` uploads its completed CSV or ZIP when `$Upload` is enabled. If `$DeleteAfterUpload` is also enabled, it deletes that same local file only after a confirmed successful upload. Disabled or failed uploads retain the file. `$Zip`, `$Upload`, and `$DeleteAfterUpload` all accept `true`, `$true`, `yes`, `y`, or `1`; `false`, `$false`, `no`, `n`, `0`, or an empty value disable the option. Casing and surrounding whitespace are ignored. The notification subject identifies the computer, and its message includes the source path and collection start time (local time with UTC offset).
The default destination is `https://xfer.emberkom.com/filedrop/cmd`. The sender defaults to the computer's hostname plus its primary DNS suffix, followed by `@ek-upload.net` (for example, `pc01.example.com@ek-upload.net`). Without a DNS suffix, it uses the hostname. Override these with `-FileDropUrl` and `-From`; `-Subject` and `-Message` are also optional.
`Upload-File` uses the [LiquidFiles FileDrop API](https://docs.liquidfiles.com/api/v4.3/filedrop/) to obtain a temporary token, upload the file, and submit the message that triggers FileDrop delivery and its configured email notification. It returns a receipt with the path, destination, sender, attachment ID, byte count, and server status. It retains the local file and throws on failure.
@@ -31,4 +30,5 @@ Offline tests (no uploads or notifications):
```powershell
powershell.exe -NoProfile -File .\tests\Upload-File.Tests.ps1
powershell.exe -NoProfile -File .\tests\Get-DSAManifest.Tests.ps1
```
+132
View File
@@ -0,0 +1,132 @@
#Requires -Version 5.0
# Offline tests: collector and upload calls are mocked; only temporary files are deleted.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$DsaScriptUnderTest = Join-Path (Split-Path $PSScriptRoot -Parent) 'Get-DSAManifest.ps1'
$DsaTokens = $null
$DsaErrors = $null
$null = [Management.Automation.Language.Parser]::ParseFile($DsaScriptUnderTest, [ref]$DsaTokens, [ref]$DsaErrors)
If ($DsaErrors.Count) { Throw ($DsaErrors | Out-String) }
Function Assert-Dsa { param([bool]$Condition, [string]$Description) If (!$Condition) { Throw $Description } }
Function Download-File {
[CmdletBinding()]
param($URL, $File)
$script:DsaDownloadCalls++
[IO.File]::WriteAllText($File, 'Mock executable; never executed.')
Return $File
}
Function Start-Process {
[CmdletBinding()]
param($FilePath, $ArgumentList, [switch]$Wait, [switch]$PassThru, $WindowStyle)
$DsaMatch = [regex]::Match($ArgumentList, '"--output"\s+"([^"]+)"')
If (!$DsaMatch.Success) { Throw 'Collector output argument missing.' }
$script:DsaCsvPath = $DsaMatch.Groups[1].Value
$script:DsaGeneratedPath = $DsaCsvPath
If ($ArgumentList -match '"--zip"') { $script:DsaGeneratedPath = [IO.Path]::ChangeExtension($DsaCsvPath, '.zip') }
If (!$DsaGeneratedPath.StartsWith($script:DsaTestRoot + '\', [StringComparison]::OrdinalIgnoreCase)) {
Throw 'Mock collector output is outside the test directory.'
}
If ($script:DsaTestMode -eq 'collector-failure') { Return [pscustomobject]@{ExitCode=7} }
If ($script:DsaTestMode -ne 'missing-output') {
[IO.File]::WriteAllText($DsaGeneratedPath, 'Synthetic manifest.')
If ($DsaGeneratedPath -ne $DsaCsvPath) {
# A same-basename CSV must not be deleted when the uploaded result is ZIP.
[IO.File]::WriteAllText($DsaCsvPath, 'Unrelated CSV sentinel.')
}
}
Return [pscustomobject]@{ExitCode=0}
}
Function Upload-File {
[CmdletBinding()]
param($Path, $Subject, $Message)
Assert-Dsa (Test-Path -LiteralPath $Path -PathType Leaf) 'File was deleted before upload.'
$script:DsaRecordedUploads += [pscustomobject]@{Path=$Path; Subject=$Subject; Message=$Message}
If ($script:DsaTestMode -eq 'upload-failure') { Throw 'Simulated upload failure.' }
If ($script:DsaTestMode -eq 'no-receipt') { Return }
If ($script:DsaTestMode -eq 'incomplete-receipt') { Return [pscustomobject]@{AttachmentId='test-attachment'} }
Return [pscustomobject]@{Path=$Path; AttachmentId='test-attachment'; Status='Filedrop message sent successfully'}
}
Function Invoke-DsaCase {
param([bool]$ZipExpected, [bool]$UploadExpected, [bool]$DeleteExpected, [string]$Mode='success', [hashtable]$Inputs)
$Zip = If ($ZipExpected) { ' YeS ' } Else { ' n ' }
$Upload = If ($UploadExpected) { ' $TrUe ' } Else { ' 0 ' }
$DeleteAfterUpload = If ($DeleteExpected) { ' 1 ' } Else { ' $FaLsE ' }
If ($null -ne $Inputs) {
$Zip = $Inputs.Zip
$Upload = $Inputs.Upload
$DeleteAfterUpload = $Inputs.DeleteAfterUpload
}
$DSAManifestSource = 'C:\Synthetic Source'
$Global:ToolsDirectory = Join-Path $script:DsaTestRoot 'Tools'
$Global:OutputDirectory = Join-Path $script:DsaTestRoot ([guid]::NewGuid().ToString('N'))
$script:DsaTestMode = $Mode
$script:DsaRecordedUploads = @()
$script:DsaDownloadCalls = 0
$script:DsaGeneratedPath = $null
$DsaCaught = $null
Try { . $DsaScriptUnderTest | Out-Null } Catch { $DsaCaught = $_ }
If ($Mode -eq 'invalid-input') {
Assert-Dsa ($null -ne $DsaCaught -and $DsaCaught.Exception.Message -like 'Invalid $* value*') 'Invalid flag was not rejected.'
Assert-Dsa ($script:DsaDownloadCalls -eq 0) 'Invalid flag must fail before downloading.'
Return
}
If ($Mode -in @('collector-failure','missing-output')) {
Assert-Dsa ($null -ne $DsaCaught -and $script:DsaRecordedUploads.Count -eq 0) 'Collection failure must prevent upload.'
Return
}
If ($Mode -eq 'success') { Assert-Dsa ($null -eq $DsaCaught) "Unexpected failure: ${DsaCaught}" }
Else { Assert-Dsa ($null -ne $DsaCaught) 'Expected upload/receipt failure.' }
$DsaExpectedExtension = If ($ZipExpected) { '.zip' } Else { '.csv' }
Assert-Dsa ([IO.Path]::GetExtension($script:DsaGeneratedPath) -eq $DsaExpectedExtension) 'Incorrect collected file type.'
$DsaExpectedCalls = If ($UploadExpected) { 1 } Else { 0 }
Assert-Dsa ($script:DsaRecordedUploads.Count -eq $DsaExpectedCalls) 'Unexpected upload call count.'
If ($UploadExpected) {
Assert-Dsa ($DsaRecordedUploads[0].Path -eq $script:DsaGeneratedPath) 'Upload targeted the wrong file.'
Assert-Dsa ($DsaRecordedUploads[0].Subject -ceq "DSA manifest file uploaded from ${Env:COMPUTERNAME}") 'Subject changed.'
$DsaExpectedMessage = "DSA collection performed on ${DSAManifestSource} at $($DsaCollectionTime.ToString('yyyy-MM-dd HH:mm:ss zzz'))"
Assert-Dsa ($DsaRecordedUploads[0].Message -ceq $DsaExpectedMessage) 'Message changed.'
}
$DsaShouldRetain = !($UploadExpected -and $DeleteExpected -and $Mode -eq 'success')
Assert-Dsa ((Test-Path -LiteralPath $script:DsaGeneratedPath) -eq $DsaShouldRetain) 'Incorrect deletion or retention behavior.'
If ($ZipExpected) { Assert-Dsa (Test-Path -LiteralPath $script:DsaCsvPath) 'Cleanup incorrectly deleted the CSV neighbor.' }
}
$script:DsaTestRoot = Join-Path ([IO.Path]::GetTempPath()) ('dsa-options-tests-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -Path $script:DsaTestRoot -ItemType Directory
$DsaOriginalToolsDirectory = $Global:ToolsDirectory
$DsaOriginalOutputDirectory = $Global:OutputDirectory
Try {
Foreach ($ZipExpected in @($false,$true)) {
Foreach ($UploadExpected in @($false,$true)) {
Foreach ($DeleteExpected in @($false,$true)) {
Invoke-DsaCase $ZipExpected $UploadExpected $DeleteExpected
Write-Host "PASS: ZIP=$ZipExpected Upload=$UploadExpected DeleteAfterUpload=$DeleteExpected"
}
}
Foreach ($Mode in @('upload-failure','no-receipt','incomplete-receipt','collector-failure','missing-output')) {
Invoke-DsaCase $ZipExpected $true $true $Mode
Write-Host "PASS: ZIP=$ZipExpected $Mode prevents deletion."
}
}
Invoke-DsaCase $false $false $false -Inputs @{}
Foreach ($Name in @('Zip','Upload','DeleteAfterUpload')) {
$Inputs = @{Zip='no'; Upload='no'; DeleteAfterUpload='no'}
$Inputs[$Name] = 'maybe'
Invoke-DsaCase $false $false $false 'invalid-input' $Inputs
}
Write-Host "PASS: empty options default to false; invalid options fail early."
Write-Host "All Get-DSAManifest tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size * 8)-bit."
}
Finally {
$Global:ToolsDirectory = $DsaOriginalToolsDirectory
$Global:OutputDirectory = $DsaOriginalOutputDirectory
$DsaResolvedRoot = (Resolve-Path -LiteralPath $script:DsaTestRoot).Path
$DsaTempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
If (!$DsaResolvedRoot.StartsWith($DsaTempRoot, [StringComparison]::OrdinalIgnoreCase) -or
(Split-Path $DsaResolvedRoot -Leaf) -notlike 'dsa-options-tests-*') { Throw 'Unexpected cleanup directory.' }
Remove-Item -LiteralPath $DsaResolvedRoot -Recurse -Force
}