From 887274a8be89c952511043453a50a570872db9ab Mon Sep 17 00:00:00 2001 From: Amit Kanfer Date: Mon, 29 Jan 2024 20:00:34 +0200 Subject: [PATCH] Agent MSI support (#212) * first draft * 6 * 6 * Preventing agent upgrade via MSI * Update src/installer/BeatPackageCompiler/BeatPackageCompiler.cs Co-authored-by: Craig MacKenzie * Update README.md * Update README.md * Fixing uninstall flow and install cleanup flow * rolling back if agent install command fails * redirecting stdout to MSI log * Failing the uninstall flow in case agent uninstall command fails * Don't attempt calling agent install if the file doesn't exist * adding BK logic (#219) * Update build.ps1 * Clean up path length Instead of re-cloning to a checkout with a shorter path, we rename the existing BK checkout. * Fix 798ff24da8f65b5b49154cd48ef04f167362b11f * Typo * Build from MANIFEST_URL and don't sign This commit implements the refactoring to allow a short term integration with the unified release. It's meant to be triggered by getting passed a $MANIFEST_URL and $DRA_WORKFLOW env var. * Update README.md Co-authored-by: Craig MacKenzie * Update README.md Co-authored-by: Craig MacKenzie * Update README.md Co-authored-by: Craig MacKenzie * Update README.md Co-authored-by: Craig MacKenzie * Redirecting stderr and removing PATH manipulation for Agent MSI * Remove cron schedule As discussed in https://elasticco.atlassian.net/browse/REL-1004?focusedCommentId=107598 * Trigger 7.17 beats DRA using schedule As the release manager can't trigger the workflow introduced in 566b9e428add549364951e1d63c3150d21faabe6 , we maintain the old way of building snapshot/staging beats artifacts by keeping the trigger pipeline only for that branch. Note: this means that this PR **MUST NOT** be backported to the elastic-stack-installers 7.17 branch. Details: https://elasticco.atlassian.net/browse/REL-1004?focusedCommentId=107973 * Making sure Agent MSI runs as an administrator * Add MSI tests for Agent Pipeline (#220) Add Install, Upgrade, and Uninstall MSI tests using Pester w/ Powershell Core * Enabling agent tests * Disable "Default" test case as it doesn't exist anymore. Don't fail build on test failure for now. * Default mode clean-up * Update build.ps1 --------- Co-authored-by: Craig MacKenzie Co-authored-by: Dimitrios Liappis Co-authored-by: William Easton Co-authored-by: William Easton --- .buildkite/pipeline.yml | 22 +- .buildkite/scripts/build.ps1 | 151 +++-- .buildkite/scripts/test.ps1 | 45 ++ .gitignore | 4 +- README.md | 25 + catalog-info.yaml | 16 +- src/CustomAction.config | 11 + src/agent-qa/Invoke-Pester.ps1 | 62 ++ src/agent-qa/Readme.md | 7 + src/agent-qa/helpers.ps1 | 538 ++++++++++++++++++ src/agent-qa/msi.tests.ps1 | 359 ++++++++++++ .../BullseyeTargets/SignMsiPackageTarget.cs | 8 +- .../BullseyeTargets/UnpackPackageTarget.cs | 8 +- src/build/Properties/launchSettings.json | 10 + src/config/config.yaml | 10 + .../BeatPackageCompiler/AgentCustomAction.cs | 107 ++++ .../BeatPackageCompiler.cs | 163 ++++-- .../Properties/launchSettings.json | 20 + src/installer/resources/elastic-agent.ico | Bin 0 -> 106916 bytes src/shared/ArtifactPackage.cs | 6 + src/shared/CmdLineOptions.cs | 11 +- src/shared/ProductConfig.cs | 6 + 22 files changed, 1436 insertions(+), 153 deletions(-) create mode 100644 .buildkite/scripts/test.ps1 create mode 100644 src/CustomAction.config create mode 100644 src/agent-qa/Invoke-Pester.ps1 create mode 100644 src/agent-qa/Readme.md create mode 100644 src/agent-qa/helpers.ps1 create mode 100644 src/agent-qa/msi.tests.ps1 create mode 100644 src/installer/BeatPackageCompiler/AgentCustomAction.cs create mode 100644 src/installer/resources/elastic-agent.ico diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 6f62fabc..dde43779 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -2,22 +2,22 @@ ### The following environment variables can be set for testing purposes via the buildkite UI or CLI ### RUN_SNAPSHOT: "true" - will run the snapshot workflow ### RUN_STAGING: "true" - will run the staging workflow -### DBRANCH: "main" - will override the dra branch param. Default is $BUILDKITE_BRANCH +### ONLY_AGENT: "true" - will build only the elastic-agent msi artifact ### steps: - - group: ":beats: Stack installers Snapshot" + - group: ":package: Stack installers Snapshot" key: "dra-snapshot" steps: - - label: ":hammer: Build stack installers Snapshot" + - label: ":construction_worker: Build stack installers / Snapshot" command: ".buildkite/scripts/build.ps1" key: "build-snapshot" - artifact_paths: "bin/out/**/*.msi" + artifact_paths: "c:/users/buildkite/esi/bin/out/**/*.msi" agents: provider: gcp image: family/ci-windows-2022 env: - WORKFLOW: "snapshot" + DRA_WORKFLOW: "snapshot" - label: ":package: DRA Publish Snapshot" if: build.branch == 'main' || build.branch =~ /^[0-9]+\.[0-9]+\$/ || build.env("RUN_SNAPSHOT") == "true" command: ".buildkite/scripts/dra-publish.sh" @@ -26,20 +26,20 @@ steps: agents: provider: gcp env: - WORKFLOW: "snapshot" - - group: ":beats: Stack installers Staging :beats:" + DRA_WORKFLOW: "snapshot" + - group: ":package: Stack installers Staging" key: "dra-staging" steps: - - label: ":hammer: Build stack installers staging" + - label: ":construction_worker: Build stack installers / Staging" if: build.branch =~ /^[0-9]+\.[0-9]+\$/ || build.env("RUN_STAGING") == "true" command: ".buildkite/scripts/build.ps1" key: "build-staging" - artifact_paths: "bin/out/**/*.msi" + artifact_paths: "c:/users/buildkite/esi/bin/out/**/*.msi" agents: provider: gcp image: family/ci-windows-2022 env: - WORKFLOW: "staging" + DRA_WORKFLOW: "staging" - label: ":package: DRA Publish staging" if: build.branch =~ /^[0-9]+\.[0-9]+\$/ || build.env("RUN_STAGING") == "true" command: ".buildkite/scripts/dra-publish.sh" @@ -48,7 +48,7 @@ steps: agents: provider: gcp env: - WORKFLOW: "staging" + DRA_WORKFLOW: "staging" notify: - slack: "#ingest-notifications" diff --git a/.buildkite/scripts/build.ps1 b/.buildkite/scripts/build.ps1 index eda299c7..b30cdf76 100755 --- a/.buildkite/scripts/build.ps1 +++ b/.buildkite/scripts/build.ps1 @@ -1,72 +1,93 @@ +$ErrorActionPreference = "Stop" +Set-Strictmode -version 3 + +if (-not (Test-Path env:MANIFEST_URL)) { + $errorMessage = "Error: Required environment variable [MANIFEST_URL] is missing." + Write-Host $errorMessage + throw $errorMessage +} + +# workaround path limitation for max 248 characters +# example: https://buildkite.com/elastic/elastic-stack-installers/builds/3104#018c5e1b-23a7-4330-ad5d-4acc69157822/74-180 +cd .. +# we can't use Rename-Item because this script runs from within the existing checkout resulting in +# Rename-Item : The process cannot access the file because it is being used by another process. +Copy-Item -Path .\elastic-stack-installers -Destination c:\users\buildkite\esi -Recurse +cd c:\users\buildkite\esi + # Read the stack version from build properties [xml]$xml = Get-Content -Path "Directory.Build.props" $ns = New-Object Xml.XmlNamespaceManager($xml.NameTable) $ns.AddNamespace("ns", "http://schemas.microsoft.com/developer/msbuild/2003") $stack_version = $xml.SelectSingleNode("//ns:PropertyGroup/ns:StackVersion", $ns).InnerText -Write-Host "Building Stack version: $stack_version" +$workflow = ${env:DRA_WORKFLOW} +if ($workflow -eq "snapshot") { + $version = $stack_version + "-" + $workflow.ToUpper() +} else { + $version = $stack_version +} -echo "~~~ Installing dotnet-sdk" +Write-Host "~~~ Building Stack version: $stack_version" + +Write-Output "~~~ Installing dotnet-sdk" & "./tools/dotnet-install.ps1" -NoPath -JSonFile global.json -Architecture "x64" -InstallDir c:/dotnet-sdk ${env:PATH} = "c:\dotnet-sdk;" + ${env:PATH} Get-Command dotnet | Select-Object -ExpandProperty Definition -echo "~~~ Reading msi certificate from vault" -$MsiCertificate=& vault read -field=cert secret/ci/elastic-elastic-stack-installers/signing_cert -$MsiPassword=& vault read -field=password secret/ci/elastic-elastic-stack-installers/signing_cert -Remove-Item Env:VAULT_TOKEN - -$cert_home="C:/.cert" -New-Item $cert_home -Type Directory -Force -[IO.File]::WriteAllBytes("$cert_home/msi_certificate.p12", [Convert]::FromBase64String($MsiCertificate)) -[IO.File]::WriteAllText("$cert_home/msi_password.txt", $MsiPassword) -echo "Certificate successfully written to $cert_home" - $client = new-object System.Net.WebClient $currentDir = $(Get-Location).Path $beats = @('auditbeat', 'filebeat', 'heartbeat', 'metricbeat', 'packetbeat', 'winlogbeat') $ossBeats = $beats | ForEach-Object { $_ + "-oss" } -$workflow = ${env:WORKFLOW} -echo "~~~ downloading beat $workflow dependencies" +# TODO remove (and all references/conditionals below) after testing; this just helps to speed up elastic-agent specific build tests +$onlyAgent = $env:ONLY_AGENT -as [string] + Remove-Item bin/in -Recurse -Force -ErrorAction Ignore New-Item bin/in -Type Directory -Force -if ($workflow -eq "snapshot") { - $version = $stack_version + "-" + $workflow.ToUpper() - $hostname = "artifacts-snapshot.elastic.co" - $response = Invoke-WebRequest -UseBasicParsing -Uri "https://$hostname/beats/latest/$version.json" - $json = $response.Content | ConvertFrom-Json - $buildId = $json.build_id - $prefix = "$hostname/beats/$buildId" -} else { - $version = $stack_version - $hostname = "artifacts-staging.elastic.co" - $response = Invoke-WebRequest -UseBasicParsing -Uri "https://$hostname/beats/latest/$version.json" - $json = $response.Content | ConvertFrom-Json - $buildId = $json.build_id - $prefix = "$hostname/beats/$buildId" -} -foreach ($beat in ($beats + $ossBeats)) { - try { - $beatName = $beat.Replace("-oss", "") - $url = "https://$prefix/downloads/beats/$beatName/$beat-$version-windows-x86_64.zip" - echo "Downloading from $url" + +$manifestUrl = ${env:MANIFEST_URL} +$response = Invoke-WebRequest -UseBasicParsing -Uri $manifestUrl +$json = $response.Content | ConvertFrom-Json + +Write-Output "~~~ Downloading $workflow dependencies" + +$urls = @() + +try { + $packageName = "elastic-agent" + $projectName = "$packageName-package" + $urls += $json.projects.$projectName.packages."$packageName-$version-windows-x86_64.zip".url + + if ($onlyAgent -eq "true") { + Write-Output "Skipping beats because env var ONLY_AGENT is set to [$env:ONLY_AGENT]" + } + else { + $projectName = "beats" + foreach ($packageName in ($beats + $ossBeats)) { + $urls += $json.projects.$projectName.packages."$packageName-$version-windows-x86_64.zip".url + } + } + foreach ($url in $urls) { + $destFile = [System.IO.Path]::GetFileName($url) + Write-Output "Downloading from $url to $currentDir/bin/in/$destFile" $client.DownloadFile( $url, - "$currentDir/bin/in/$beat-$version-windows-x86_64.zip" + "$currentDir\bin\in\$destFile" ) } - catch [System.Net.WebException] { - if ($_.Exception.InnerException) { - Write-Error $_.Exception.InnerException.Message - } else { - Write-Error $_.Exception.Message - } - throw "An error was encountered while downloading dependencies, aborting." +} catch [System.Net.WebException] { + if ($_.Exception.InnerException) { + Write-Error $_.Exception.InnerException.Message + } else { + Write-Error $_.Exception.Message } + throw "An error was encountered while downloading dependencies, aborting." } -echo "--- Building $workflow msi" -$args = @( +Remove-Item bin/out -Recurse -Force -ErrorAction Ignore + +Write-Output "--- Building $workflow msi" +$cliArgs = @( "run", "--project", "src\build\ElastiBuild.csproj", @@ -75,25 +96,41 @@ $args = @( "--", "build", "--cid", - $version, - "--cert-file", - "$cert_home/msi_certificate.p12", - "--cert-pass", - "$cert_home/msi_password.txt" + $version ) -$args += ($beats + $ossBeats) -&dotnet $args +$cliArgs += "elastic-agent" +if ($onlyAgent -ne "true") { + $cliArgs += ($beats + $ossBeats) +} + +&dotnet $cliArgs if ($LastExitcode -ne 0) { - Write-Error "Build$workflow failed with exit code $LastExitcode" + Write-Error "Build $workflow failed with exit code $LastExitcode" exit $LastExitcode } else { - echo "Build$workflow completed with exit code $LastExitcode" + Write-Output "Build $workflow completed with exit code $LastExitcode" } -echo "--- Checking that all artefacts are there" +Write-Output "--- Checking that all artifacts are there" $msiCount = Get-ChildItem bin/out -Include "*.msi" -Recurse | Measure-Object | Select-Object -ExpandProperty Count -$expected = 2 * $beats.Length + +$expected = 1 +if ($onlyAgent -ne "true") { + $expected += (2 * $beats.Length) +} + if ($msiCount -ne $expected) { - Write-Error "Expected $expected msi executable to be produced, but $msiCount were" + Write-Error "Failed: expected $expected msi executables to be produced, but $msiCount were found." exit 1 +} else { + Write-Output "Success, found $msiCount artifacts in bin/out." } + +try { + & (Join-Path $PSScriptRoot "test.ps1") + write-host "Testing Completed" +} catch { + write-host "Testing Failed" + write-error $_ + exit 1 +} \ No newline at end of file diff --git a/.buildkite/scripts/test.ps1 b/.buildkite/scripts/test.ps1 new file mode 100644 index 00000000..a101aa27 --- /dev/null +++ b/.buildkite/scripts/test.ps1 @@ -0,0 +1,45 @@ +$ErrorActionPreference = "Stop" +Set-Strictmode -version 3 + +write-host (ConvertTo-Json $PSVersiontable -Compress) +write-host "Running as: $([Environment]::UserName)" + +write-host "`$env:AGENT = $($Env:AGENT)" + +if ($psversiontable.psversion -lt "7.4.0") { + # Download Powershell Core, and rerun this script using Powershell Core + + write-host "Downloading Powershell Core" + invoke-webrequest -uri https://github.com/PowerShell/PowerShell/releases/download/v7.4.0/PowerShell-7.4.0-win-x64.zip -outfile pwsh.zip + + write-host "Expanding Powershell Core" + Expand-Archive pwsh.zip -destinationpath (Join-Path $PSScriptRoot "pwsh") + + Write-host "Invoking from Powershell Core" + & (Join-Path $PSScriptRoot "pwsh/pwsh.exe") -file (Join-Path $PSScriptRoot "test.ps1") + + if ($LASTEXITCODE -eq 0) { + write-host "Child pwsh process exited successfully" + exit 0 + } else { + write-host "Child pwsh process returned $LASTEXITCODE, a non zero exit code" + throw "Tests failed." + } +} + +$AgentMSI = Get-ChildItem bin/out -Include "elastic-agent*.msi" -Recurse + +if ($AgentMSI -eq $null) { + write-error "No agent MSI found to test" +} + + +$OldAgentMSI = (Join-Path $PSScriptRoot "elastic-agent-8.11.4-windows-x86_64.msi") +if (-not (test-path $OldAgentMSI)) { + Write-Host "Downloading older MSI for upgrade tests" + invoke-webrequest -uri https://storage.googleapis.com/agent-msi-testing/elastic-agent-8.11.4-windows-x86_64.msi -outfile $OldAgentMSI +} + +& (Join-Path $PSScriptRoot "../../src/agent-qa/Invoke-Pester.ps1") -PathToLatestMSI $AgentMSI.Fullname -PathToEarlyMSI $OldAgentMSI + +write-host "Returned from Pester Test" \ No newline at end of file diff --git a/.gitignore b/.gitignore index c13f70b2..ca373b12 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,6 @@ src/*.cmd # Executable Artifacts src/*.exe src/*.pdb -src/*.dll \ No newline at end of file +src/*.dll +pwsh +pwsh.zip \ No newline at end of file diff --git a/README.md b/README.md index 80ff5ffa..8ded0d11 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,28 @@ Update version in `Directory.Build.props` in the branch for the related minor ve - Add a new daily schedule for the new minor branch - Remove the daily schedule for the previous minor branch ex: https://github.com/elastic/elastic-stack-installers/pull/156 and https://github.com/elastic/elastic-stack-installers/pull/172 + +--- +## Agent + +In case of problems during install / uninstall of agent, please refer to the [Capturing Logs](https://github.com/elastic/elastic-stack-installers/blob/agent_support/README.md#capturing-logs) section which will enable troubleshooting. + +### Install +During the install flow, The MSI installer will unpack the contents of the MSI to a temp folder and then will call the `elastic-agent install` in order to: +1. copy the files to the final destination at `c:\Program Files\Elastic\Agent` +2. register the agent as a windows service +3. enroll the agent into fleet + +In order to complete step 3 above, the MSI installer shall receive command line arguments, passed with INSTALLARGS command line switch followed by `"`, for example: +``` +elastic-agent.msi INSTALLARGS="--url= --enrollment-token=" +``` + +Note that the MSI will call the `elastic-agent install` command with `-f` (force) to avoid user interaction. + +### Uninstall +Similarly to the install flow (described above), the MSI will call the `elastic-agent uninstall` command, and it's possible to pass arguments using `INSTALLARGS`. One common use case is uninstalling an agent which has tamper protection enabled. + +### Upgrade +The Agent MSI doesn't support upgrade. Since the agents are fleet managed, upgrades shall be done using fleet (UI / API). + diff --git a/catalog-info.yaml b/catalog-info.yaml index 8ef48af8..0129e50b 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -53,11 +53,6 @@ spec: publish_commit_status: true publish_commit_status_per_step: false repository: elastic/elastic-stack-installers - schedules: - Daily main: - branch: main - cronline: "@daily" - message: Builds daily `main` stack-installers dra teams: everyone: access_level: BUILD_AND_READ @@ -93,15 +88,8 @@ spec: publish_commit_status_per_step: false repository: elastic/elastic-stack-installers schedules: - Daily 8_12: - branch: "8.12" - cronline: "*/10 * * * *" - message: Checking for new beats artifacts for `8.12` - Daily 8_11: - branch: "8.11" - cronline: "*/10 * * * *" - message: Checking for new beats artifacts for `8.11` - Weekly main: + # trigger for 7_17 still needed for now, details in https://elasticco.atlassian.net/browse/REL-1004?focusedCommentId=107973 + Daily 7_17: branch: "7.17" cronline: "*/10 * * * *" message: Checking for new beats artifacts for `7.17` diff --git a/src/CustomAction.config b/src/CustomAction.config new file mode 100644 index 00000000..3d8084a4 --- /dev/null +++ b/src/CustomAction.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/agent-qa/Invoke-Pester.ps1 b/src/agent-qa/Invoke-Pester.ps1 new file mode 100644 index 00000000..e037eb15 --- /dev/null +++ b/src/agent-qa/Invoke-Pester.ps1 @@ -0,0 +1,62 @@ +#Requires -RunAsAdministrator +param ( + $PathToLatestMSI = (Join-Path $PSScriptRoot "bin/elastic-agent-8.13.0-windows-x86_64.msi"), + $PathToEarlyMSI = (Join-Path $PSScriptRoot "bin/elastic-agent-8.11.4-windows-x86_64.msi") +) + + +Set-Strictmode -version 3 +$ErrorActionPreference = "Stop" + +Start-Transcript -Path pester.log -Append -IncludeInvocationHeader + +# Passing data to pester requires version 5.1.0 +try { + Import-Module Pester -MinimumVersion 5.1.0 +} catch { + Write-warning "Pester 5.1.0 or later is required. Installing now." + Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck +} +# Passing data to pester requires version 5.1.0 +try { + Import-Module FindOpenFile +} catch { + Write-warning "FindOpenFile is required. Installing now." + Install-Module -Name FindOpenFile -Force -Scope CurrentUser -SkipPublisherCheck +} + +# Clean-up our environment +$testsdir = $PSScriptRoot +$logsdir = Join-Path $PSScriptRoot "logs" + +remove-item $logsdir -Recurse -ErrorAction SilentlyContinue +new-item $logsdir -ItemType directory -ErrorAction SilentlyContinue + +# Data to pass to tests +$data = @{ + PathToEarlyMSI = $PathToEarlyMSI + PathToLatestMSI = $PathToLatestMSI + LogsDir = $logsdir + VerbosePreference = "continue" # Comment out to disable verbose logging during test runs +} + +if (-not (test-path ($Data.PathToEarlyMSI))) { + throw "Missing early MSI version for upgrade testing" +} +if (-not (test-path ($Data.PathToLatestMSI))) { + throw "Missing latest MSI version for upgrade testing" +} + +$container = New-PesterContainer -Path $testsdir\*.tests.ps1 -Data $data + +$config = [PesterConfiguration] @{ + Run = @{ + Throw = $True + Container = $container + } + Output = "Detailed" +} + +Invoke-Pester -Configuration $config + +Stop-Transcript \ No newline at end of file diff --git a/src/agent-qa/Readme.md b/src/agent-qa/Readme.md new file mode 100644 index 00000000..8c76516d --- /dev/null +++ b/src/agent-qa/Readme.md @@ -0,0 +1,7 @@ +Pester test suite for testing Elastic Agent MSIs + +Place a 8.10.4 and an 8.11.1 agent in a bin folder and run Invoke-Pester. + +You will have to modify code if you want to run other versions. + +It runs the MSI installer in various scenarios including Default, Standalone, and Fleet, all which pass different parameters to the MSI installer. It then tests the MSI exit code and other conditions to determine if the test passed. \ No newline at end of file diff --git a/src/agent-qa/helpers.ps1 b/src/agent-qa/helpers.ps1 new file mode 100644 index 00000000..3c52def2 --- /dev/null +++ b/src/agent-qa/helpers.ps1 @@ -0,0 +1,538 @@ +$Script:AgentInstallCache = "C:\Program Files\Elastic\Beats" +$Script:AgentPath = "C:\Program Files\Elastic\Agent" +$Script:AgentBinary = "elastic-agent.exe" + +$Script:LogDir = (Join-Path $PSScriptRoot "logs") + +Function Get-LogDir { + return $Script:LogDir +} + +Function Get-AgentUninstallRegistryKey { + param ( + [switch] $Passthru + ) + + $Result = @(@(Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\") | Where-Object { + $_.GetValue("DisplayName") -Like "Elastic Agent *" + }) + if ($Result.Length -eq 0) { return; } + + if ($Passthru) { return $Result } + + return $Result.Name +} + +Function Get-AgentUninstallGUID { + $RegistryKey = Get-AgentUninstallRegistryKey -Passthru + + try { + $GUID = $RegistryKey.PSChildName + return $GUID + } + catch {} +} + +Function Run-AgentChecks { + param ( + $Functions, + [switch] $ReturnErrors, + [switch] $ShouldFail + ) + + $Errors = @() + foreach ($AgentFunction in $Functions) { + $Success = & $AgentFunction + if ($ShouldFail) { + if ($Success) { + $Errors += "$($AgentFunction.Ast.Name)" + } + } + else { + if (-Not $Success) { + $Errors += "$($AgentFunction.Ast.Name)" + } + } + } + + if ($ReturnErrors) { return $Errors } + + return ($Errors.length -eq 0) +} + +Function Is-AgentServicePresent { + + if (Get-Service -Name "Elastic Agent" -ErrorAction SilentlyContinue) { + return $true + } + + return $false +} + +Function Is-AgentServiceRunning { + param ( + [switch] $Resolve, + [switch] $WaitForExit + ) + + if (-not (Is-AgentServicePresent)) { return $False } + + $agent = Get-Service -Name "Elastic Agent" -ErrorAction SilentlyContinue + + if ($agent.Status -eq "Running") { + return $true + } elseif ($resolve) { + Start-Service "Elastic Agent" + if ($WaitForExit) { + Wait-Process -Name "elastic-agent" -Timeout 30 -ErrorAction SilentlyContinue + } else { + Start-Sleep -Seconds 3 + } + return (Is-AgentServiceRunning) + } + + return $false +} + +Function Is-AgentAttemptingEnrollment { + if (-not (Is-AgentProducingLogs)) { return $null } + + $LogFiles = Get-AgentLogFile -Latest $True + + foreach ($LogFile in $LogFiles) { + $content = Get-Content $LogFile.FullName + + try { + $json = convertfrom-json $content -AsHashtable + if ($json["log.level"] -eq "error") { + $Errors += $content + } + } + catch { + + } + } +} + +Function Is-AgentLoggingErrors { + if (-not (Is-AgentProducingLogs)) { return $null } + + $LogFiles = Get-AgentLogFile -Latest $True + + $Errors = @() + + foreach ($LogFile in $LogFiles) { + $content = Get-Content $LogFile.FullName + + try { + $json = convertfrom-json $content -AsHashtable + if ($json["log.level"] -eq "error") { + $Errors += $content + } + } + catch { + + } + + if ($content -match "FATAL") { + $Errors += $content + } + } + + if ($Errors.Count -gt 0) { + return $false + } + + return $true +} + +# Find Elastic-Agent and msiexec processes running as current user to indicate an ongoing installer action +Function Has-AgentInstallerProcess { + $msiexec = @(get-process "msiexec" -IncludeUserName -ErrorAction SilentlyContinue | Where-Object {$_.Username -ne "NT AUTHORITY\SYSTEM" }) + + if ($msiexec.length -ne 0) { return $true} + + $msiexec = @(get-process "elastic-agent" -IncludeUserName -ErrorAction SilentlyContinue | Where-Object {$_.Username -ne "NT AUTHORITY\SYSTEM" }) + + if ($msiexec.length -ne 0) { return $true} + + return $False +} + +Function Has-AgentLogged { + try { + $LogFile = Get-AgentLogFile -Latest $True + return $true + } + catch { + return $false + } +} + +Function Is-AgentLogGrowing { + try { + $LogFile = Get-AgentLogFile -Latest $True + } + catch { + return $false + } + + $Timeout = 15 + $Time = 0 + $PreviousSize = (Get-Item $LogFile).Length + + while ($Time -lt $Timeout) { + $CurrentSize = (Get-Item $LogFile).Length + + if ($CurrentSize -gt $PreviousSize) { + return $true + } + + $Time ++ + start-sleep -seconds 1 + } + + return $false +} + +Function Has-AgentStandaloneLog { + try { + $LogFile = Get-AgentLogFile -Latest $True + } + catch { + return $false + } + + $Content = Get-Content -raw $LogFile + + if ($Content -like "*Parsed configuration and determined agent is managed locally*") { + return $True + } + + return $false +} + +Function Has-AgentFleetEnrollmentAttempt { + try { + $LogFile = Get-AgentLogFile -Latest $True + } + catch { + return $false + } + + $Content = Get-Content -raw $LogFile + + if ($Content -like "*failed to perform delayed enrollment: fail to enroll: fail to execute request to fleet-server: lookup placeholder: no such host*") { + return $True + } + + return $false +} + +Function Is-AgentFleetEnrolled { + $path = (Join-Path $Script:AgentPath "fleet.enc") + if (Test-Path $path) { + return $true + } + return $false +} + +Function Is-AgentUninstallKeyPresent { + + $RegistryKey = @(Get-AgentUninstallRegistryKey) + if ($RegistryKey.length -ne 0) { + return $true + } + + Return $false +} + +Function Is-AgentInstallCacheGone { + return (-not (Is-AgentInstallCachePresent)) +} + +Function Is-AgentInstallCachePresent { + $path = $Script:AgentInstallCache + if (Test-Path $path) { + return $true + } + return $false +} + +Function Get-AgentInstallCacheCount { + $path = $Script:AgentInstallCache + if (Test-Path $path) { + return @(Get-ChildItem -Path $Script:AgentInstallCache -Recurse).Length + } + return 0 +} + +Function Is-AgentBinaryPresent { + $path = (Join-Path $Script:AgentPath $Script:AgentBinary) + if (Test-Path $path) { + return $true + } + return $false +} + +Function Get-AgentLogFile { + param ( + $Latest + ) + + + $LogFiles = @(Get-ChildItem -Path (get-item "C:\Program Files\Elastic\Agent\data\*\logs").fullname -Filter "*.ndjson" | Where-Object {$_.name -notlike "*watcher*"} | Sort-Object LastWriteTime) + + if ($LogFiles.Count -eq 0) { + throw "No log files found" + } + + if ($Latest) { + return $LogFiles | Select-Object -last 1 + } + + return $LogFiles +} + + +Function Clean-ElasticAgent { + + try { Clean-ElasticAgentInstaller } catch { } + + Clean-ElasticAgentUninstallKeys + + Clean-ElasticAgentService + + Clean-ElasticAgentProcess + + Clean-ElasticAgentDirectory +} + +Function Clean-ElasticAgentInstaller { + + $UninstallGuid = Get-AgentUninstallGUID + if (-not $UninstallGuid) { return } + + Uninstall-MSI -Guid $UninstallGuid -LogToDir (Get-LogDir) +} + +Function Clean-ElasticAgentService { + + if (Get-Service 'Elastic Agent' -ErrorAction SilentlyContinue) { + Stop-Service 'Elastic Agent' + Remove-Service 'Elastic Agent' + } +} + +Function Clean-ElasticAgentProcess { + if (Get-Process -Name 'elastic-agent.exe' -erroraction SilentlyContinue) { + Stop-Process -name "elastic-agent.exe" + } +} + +Function Clean-ElasticAgentDirectory { + $Path = Join-Path ($Env:ProgramFiles) "Elastic\Agent" + if (Test-Path $Path) { + Remove-Item -Recurse -Path $Path -Force + } + + $Path = Join-Path ($Env:ProgramFiles) "Elastic\Beats" + if (Test-Path $Path) { + Remove-Item -Recurse -Path $Path -Force + } +} + +Function Clean-ElasticAgentUninstallKeys { + $Keys = Get-AgentUninstallRegistryKey -Passthru + if ($Keys) { + Remove-Item -Path $Keys + } +} + +Function New-MSIVerboseLoggingDestination { + param ( + $Destination, + $Prefix, + $Suffix + ) + + $Timestamp = get-date -format "yyyyMMdd-hhmmss" + $Name = (@($Prefix, $Timestamp, $Suffix).Where{ $_ } -join "-") + ".log" + $SanitizedDestination = Resolve-Path -path $Destination + $Path = (Join-Path $SanitizedDestination $Name) + + return $Path +} + +Function Invoke-MSIExec { + param ( + $Action, + $Arguments, + $LogToDir + ) + + $arglist = "/$Action $($Arguments -join " ")" + + if ($LogToDir) { + $LoggingDestination = (New-MSIVerboseLoggingDestination -Destination $LogToDir -Suffix $Action) + $arglist += " /l*v " + $LoggingDestination + } + + write-verbose "Invoking msiexec.exe $arglist" + $process = start-process -FilePath "msiexec.exe" -ArgumentList $arglist -PassThru + $thisPid = $Process.id + + # Wait for the process to exit but only for 10 minutes and then terminate it + Wait-Process -id $thisPid -Timeout 600 -ErrorVariable timeouted + if ($timeouted) + { + # terminate the process + write-warning "Killing process $thisPid as it reached timeout ($timeouted)" + stop-process -id $thisPid + } + + if ($process.ExitCode -ne 0) { + $Message = "msiexec reports error $($process.ExitCode) = $(Get-MSIErrorMessage -Code $Process.ExitCode)" + try { + if ($LogToDir -and $Action -eq "x") { + $CustomActionLog = Select-String -Path $LoggingDestination -Pattern 'Calling custom action BeatPackageCompiler!Elastic.PackageCompiler.Beats.AgentCustomAction.UnInstallAction' -Context 0,30 + write-warning "Elastic Agent uninstall returned:" + write-warning ($CustomActionLog.Context.PostContext -join "`n") + } elseif ($LogToDir -and $Action -eq "i") { + $CustomActionLog = Select-String -Path $LoggingDestination -Pattern 'Calling custom action BeatPackageCompiler!Elastic.PackageCompiler.Beats.AgentCustomAction.InstallAction' -Context 0,30 + write-warning "Elastic Agent uninstall returned:" + write-warning ($CustomActionLog.Context.PostContext -join "`n") + } + } catch { + write-warning "Failed to parse msi log for errors" + write-warning "Dumping full MSI Log" + write-host (Get-Content $LoggingDestination -raw) + } + + write-verbose $Message + + Throw $Message + } + + write-verbose "msiexec reports success with return code 0." +} + +Function Install-MSI { + param ( + $path, + $flags, + $Interactive = "/qn", + $LogToDir + ) + + $msiArgs = @(@($Path) + $Interactive + $Flags) + + Invoke-MSIExec -Action i -Arguments $msiArgs -LogToDir $LogToDir +} + +# Uninstall MSI based on path to MSI or GUID +Function Uninstall-MSI { + param ( + $path, + $guid, + $flags, + $Interactive = "/qn", + $LogToDir + ) + + if (-not $Path -and -not $Guid){ + throw "Uninstall-msi called without path to an MSI or a GUID" + } + + $msiArgs = @($Path,$Guid).Where{$_} + $Interactive + $Flags + + $OpenFiles = @(Find-OpenFile | Where-Object {$_.Name -like "*Elastic\Agent*" -or $_.Name -like "*Elastic\Beats*"}) + foreach ($OpenFile in $OpenFiles) { + write-host "Open file may block uninstall $($OpenFile.Name) with PID $($OpenFile.ProcessID) opened by $((Get-Process -ID $OpenFile.ProcessID).ProcessName)" + } + + try { + Invoke-MSIExec -Action x -Arguments $msiArgs -LogToDir $LogToDir + } + catch { + # Find open files in the Elastic\Agent directory + $OpenFiles = @(Find-OpenFile | Where-Object {$_.Name -like "*Elastic\Agent*" -or $_.Name -like "*Elastic\Beats*"}) + foreach ($OpenFile in $OpenFiles) { + write-warning "Found open file $($OpenFile.Name) with PID $($OpenFile.ProcessID) opened by $((Get-Process -ID $OpenFile.ProcessID).ProcessName)" + } + throw $_ + } + +} + + + +Function Get-MSIErrorMessage { + param ( + $Code + ) + + return $Script:CodeToMessage[$Code] +} + +$Script:CodeToMessage = @{ + 0 = "The action completed successfully." + 13 = "data is invalid." + 87 = "of the parameters was invalid." + 120 = "value is returned when a custom action attempts to call a function that can't be called from custom actions. The function returns the value ERROR_CALL_NOT_IMPLEMENTED." + 1259 = "Windows Installer determines a product might be incompatible with the current operating system, it displays a dialog box informing the user and asking whether to try to install anyway. This error code is returned if the user chooses not to try the installation." + 1601 = "Windows Installer service couldn't be accessed. Contact your support personnel to verify that the Windows Installer service is properly registered." + 1602 = "user canceled installation." + 1603 = "fatal error occurred during installation." + 1604 = "suspended, incomplete." + 1605 = "action is only valid for products that are currently installed." + 1606 = "feature identifier isn't registered." + 1607 = "component identifier isn't registered." + 1608 = "is an unknown property." + 1609 = "handle is in an invalid state." + 1610 = "configuration data for this product is corrupt. Contact your support personnel." + 1611 = "component qualifier not present." + 1612 = "installation source for this product isn't available. Verify that the source exists and that you can access it." + 1613 = "installation package can't be installed by the Windows Installer service. You must install a Windows service pack that contains a newer version of the Windows Installer service." + 1614 = "product is uninstalled." + 1615 = "SQL query syntax is invalid or unsupported." + 1616 = "record field does not exist." + 1618 = "installation is already in progress. Complete that installation before proceeding with this install. For information about the mutex, see _MSIExecute Mutex." + 1619 = "installation package couldn't be opened. Verify that the package exists and is accessible, or contact the application vendor to verify that this is a valid Windows Installer package." + 1620 = "installation package couldn't be opened. Contact the application vendor to verify that this is a valid Windows Installer package." + 1621 = "was an error starting the Windows Installer service user interface. Contact your support personnel." + 1622 = "was an error opening installation log file. Verify that the specified log file location exists and is writable." + 1623 = "language of this installation package isn't supported by your system." + 1624 = "was an error applying transforms. Verify that the specified transform paths are valid." + 1625 = "installation is forbidden by system policy. Contact your system administrator." + 1626 = "function couldn't be executed." + 1627 = "function failed during execution." + 1628 = "invalid or unknown table was specified." + 1629 = "data supplied is the wrong type." + 1630 = "of this type isn't supported." + 1631 = "Windows Installer service failed to start. Contact your support personnel." + 1632 = "Temp folder is either full or inaccessible. Verify that the Temp folder exists and that you can write to it." + 1633 = "installation package isn't supported on this platform. Contact your application vendor." + 1634 = "isn't used on this machine." + 1635 = "patch package couldn't be opened. Verify that the patch package exists and is accessible, or contact the application vendor to verify that this is a valid Windows Installer patch package." + 1636 = "patch package couldn't be opened. Contact the application vendor to verify that this is a valid Windows Installer patch package." + 1637 = "patch package can't be processed by the Windows Installer service. You must install a Windows service pack that contains a newer version of the Windows Installer service." + 1638 = "version of this product is already installed. Installation of this version can't continue. To configure or remove the existing version of this product, use Add/Remove Programs in Control Panel." + 1639 = "command line argument. Consult the Windows Installer SDK for detailed command-line help." + 1640 = "current user isn't permitted to perform installations from a client session of a server running the Terminal Server role service." + 1641 = "installer has initiated a restart. This message indicates success." + 1642 = "installer can't install the upgrade patch because the program being upgraded may be missing or the upgrade patch updates a different version of the program. Verify that the program to be upgraded exists on your computer and that you have the correct upgrade patch." + 1643 = "patch package isn't permitted by system policy." + 1644 = "or more customizations aren't permitted by system policy." + 1645 = "Installer doesn't permit installation from a Remote Desktop Connection." + 1646 = "patch package isn't a removable patch package." + 1647 = "patch isn't applied to this product." + 1648 = "valid sequence could be found for the set of patches." + 1649 = "removal was disallowed by policy." + 1650 = "XML patch data is invalid." + 1651 = "user failed to apply patch for a per-user managed or a per-machine application that'is in advertised state." + 1652 = "Installer isn't accessible when the computer is in Safe Mode. Exit Safe Mode and try again or try using system restore to return your computer to a previous state. Available beginning with Windows Installer version 4.0." + 1653 = "t perform a multiple-package transaction because rollback has been disabled. Multiple-package installations can't run if rollback is disabled. Available beginning with Windows Installer version 4.5." + 1654 = "app that you're trying to run isn't supported on this version of Windows. A Windows Installer package, patch, or transform that has not been signed by Microsoft can't be installed on an ARM computer." + 3010 = "restart is required to complete the install. This message indicates success. This does not include installs where the ForceReboot action is run." +} \ No newline at end of file diff --git a/src/agent-qa/msi.tests.ps1 b/src/agent-qa/msi.tests.ps1 new file mode 100644 index 00000000..4e4b1619 --- /dev/null +++ b/src/agent-qa/msi.tests.ps1 @@ -0,0 +1,359 @@ +param ( + $PathToEarlyMSI, + $PathToLatestMSI +) + +### +# The 2 agent modes (standalone, fleet) are each a test case for the tests +# The health function represents how we determine if the agent is healthy in that mode +# and is frequently invoked after installing the agent. + +# Test cases have to be generated before discovery so nothing in this section is usable in tests +BeforeDiscovery { + Import-Module (Join-Path $PSScriptRoot "helpers.ps1") -Force -Verbose:$False + + $Testcases = @( + @{ + Mode = "Standalone"; + HealthFunction = { + if (Is-AgentInstallCachePresent) { + write-warning "Agent Install Cache Program Files/Elastic/Beats is still present with $(Get-AgentInstallCacheCount) entries" + } + #Is-AgentInstallCachePresent | Should -BeFalse + Is-AgentUninstallKeyPresent | Should -BeTrue + Is-AgentBinaryPresent | Should -BeTrue + Is-AgentServicePresent | Should -BeTrue + Is-AgentServiceRunning -Resolve | Should -BeTrue # Start the service if it's not running and expect it to remain running + Has-AgentLogged | Should -BeTrue + Has-AgentStandaloneLog | Should -BeTrue + } + MSIInstallParameters = @{ + Flags = '/norestart INSTALLARGS="-v"' + LogToDir = Get-LogDir + } + MSIUninstallParameters = @{ + LogToDir = Get-LogDir + } + } + @{ + Mode = "Fleet"; + HealthFunction = { + if (Is-AgentInstallCachePresent) { + write-warning "Agent Install Cache Program Files/Elastic/Beats is still present with $(Get-AgentInstallCacheCount) entries" + } + #Is-AgentInstallCachePresent | Should -BeFalse + Is-AgentUninstallKeyPresent | Should -BeTrue + Is-AgentBinaryPresent | Should -BeTrue + Is-AgentServicePresent | Should -BeTrue + + # Start the service but don't expect it to be running as our fleet config causes agent to stop right away + Is-AgentServiceRunning -Resolve -WaitForExit + + Has-AgentLogged | Should -BeTrue + Has-AgentFleetEnrollmentAttempt | Should -BeTrue + } + MSIInstallParameters = @{ + Flags = '/norestart INSTALLARGS="--delay-enroll --url=https://placeholder:443 --enrollment-token=token"' + LogToDir = Get-LogDir + } + MSIUninstallParameters = @{ + LogToDir = Get-LogDir + } + } + ) +} + +# Stuff starting in this section is usable in tests +BeforeAll { + #$VerbosePreference = "Continue" + Import-Module (Join-Path $PSScriptRoot "helpers.ps1") -Force -Verbose:$False + + Function Check-AgentRemnants { + Is-AgentBinaryPresent | Should -BeFalse -Because "The agent should have been cleaned up already" + Is-AgentFleetEnrolled | Should -BeFalse -Because "The agent should have been cleaned up already" + Is-AgentServiceRunning | Should -BeFalse -Because "The agent should have been cleaned up already" + Is-AgentServicePresent | Should -BeFalse -Because "The agent should have been cleaned up already" + Is-AgentUninstallKeyPresent | Should -BeFalse -Because "The agent should have been cleaned up already" + Is-AgentInstallCachePresent | Should -BeFalse -Because "The agent should have been cleaned up already" + } + + # Perform an initial environment cleanup + try { + Check-AgentRemnants + } + catch { + write-warning "Found Agent remnants before tests started. Removing Agent." + Clean-ElasticAgent + } +} + +Describe 'Elastic Agent MSI Installer' { + # Ideally, all tests perfectly clean up after themselves but if the previous test failed we want to perform our own + # clean-up but we don't want that clean-up to count against our new test. + BeforeEach { + try { + Check-AgentRemnants + } + catch { + write-warning "Found Agent remnants between tests. Removing Agent." + Clean-ElasticAgent + } + + # Reduce filesystem async race conditions - spooky voodoo + Write-VolumeCache c + Start-Sleep -Seconds 5 + } + + # All tests in these sections must remove Agent before finishing. If they do not, the test will fail + AfterEach { + Check-AgentRemnants + + Has-AgentInstallerProcess | Should -BeFalse -Because "There should be no dangling installer processes after a test run" + } + + # Using the test cases defined up above we test the agent in Standalone and Fleet modes + Context "Basic Tests for mode" -ForEach $Testcases { + + BeforeAll { + Function Assert-AgentHealthy { + & $HealthFunction + } + } + + It 'Can be installed and uninstalled via MSI, with installargs, in mode' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + Assert-AgentHealthy + + Uninstall-MSI -Path $PathToLatestMSI @MSIUninstallParameters -Flags 'INSTALLARGS="-v"' + + Check-AgentRemnants + } + + It 'Can be installed and uninstalled via GUID, without installargs, in mode' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + Assert-AgentHealthy + + { Get-AgentUninstallGUID } | Should -Not -Throw + $UninstallGuid = Get-AgentUninstallGUID + + Uninstall-MSI -Guid $UninstallGuid @MSIUninstallParameters + + Check-AgentRemnants + } + + It 'Gracefully handles existing agent components' { + + # Create a fake service + new-service -Name "Elastic Agent" -BinaryPathName "cmd.exe" -DisplayName "Fake Service" -StartupType manual + + # Create a fake directory + new-item -itemtype directory "C:\Program Files\Elastic\Agent\data\elastic-agent-03ef9d\logs" + + # Create a fake config + set-content "C:\Program Files\Elastic\Agent\elastic-agent.exe" -value "test" -nonewline + + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + # Remove our fakes and see if everything passes + if ((get-content "C:\Program Files\Elastic\Agent\elastic-agent.exe" -raw) -eq "test") { + remove-item "C:\Program Files\Elastic\Agent\elastic-agent.exe" + } + if ((Get-Service "Elastic Agent" -Erroraction SilentlyContinue).DisplayName -eq "Fake Service") { + Remove-Service "Elastic Agent" + } + + Assert-AgentHealthy + + # We clean-up with a clean-up script so that we can differentiate installer from uninstaller failures with the next test + Clean-ElasticAgent + + Check-AgentRemnants + } + It 'Blocks downgrades and upgrades in mode' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + Assert-AgentHealthy + + { Install-MSI -Path $PathToEarlyMSI -Interactive "/qn" @MSIInstallParameters } | Should -Throw + + Assert-AgentHealthy + + Uninstall-MSI -Path $PathToLatestMSI @MSIUninstallParameters + + Check-AgentRemnants + } + + It 'Blocks upgrades in mode' { + Install-MSI -Path $PathToEarlyMSI @MSIInstallParameters + + Assert-AgentHealthy + + { Install-MSI -Path $PathToLatestMSI -Interactive "/qn" @MSIInstallParameters } | Should -Throw + + Assert-AgentHealthy + + Uninstall-MSI -Path $PathToEarlyMSI + + Check-AgentRemnants + } + } + + Context "Specific tests for Standalone Mode (with installargS=-v)" -Foreach $Testcases[0] { + BeforeAll { + Function Assert-AgentHealthy { + & $HealthFunction + } + } + It 'Behaves itself as a standalone agent' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + Assert-AgentHealthy + + Uninstall-MSI -Path $PathToLatestMSI @MSIUninstallParameters + + Check-AgentRemnants + } + + It 'Can be installed in Standalone mode and uninstalled via GUID in default mode' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + Assert-AgentHealthy + + { Get-AgentUninstallGUID } | Should -Not -Throw + $UninstallGuid = Get-AgentUninstallGUID + + Uninstall-MSI -Guid $UninstallGuid @MSIUninstallParameters + + Check-AgentRemnants + } + + It 'Can be installed and uninstalled via MSI, with invalid installargs, in standalone mode' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + Assert-AgentHealthy + + { Uninstall-MSI -Path $PathToLatestMSI @MSIUninstallParameters -Flags 'INSTALLARGS="--delayenroll"' } | Should -Throw + + Uninstall-MSI -Path $PathToLatestMSI @MSIUninstallParameters -Flags 'INSTALLARGS="-v"' + + Check-AgentRemnants + } + } + + Context "Specific tests for Fleet Mode with delayed enroll" -Foreach $Testcases[1] { + BeforeAll { + Function Assert-AgentHealthy { + & $HealthFunction + } + } + + It 'Rollback uninstall when elastic-agent uninstall crashes' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + # Start the MSI in a background job so that we can kill a child process and measure success + $Job = Start-Job -WorkingDirectory $PSScriptRoot -ScriptBlock { + $arglist = "/x $using:PathToLatestMSI /qn" + + write-information "msiexec $arglist" + $process = start-process -FilePath "msiexec.exe" -ArgumentList $arglist -wait -passthru + + write-output $process.ExitCode + } + + $Time = 0 + while (-not (get-process "elastic-agent" -erroraction silentlycontinue) -and $Time -lt 45) { + start-sleep -seconds 1 + $Time ++ + } + + $Time | Should -BelessThan 45 -Because "otherwise we timed out waiting for the agent" + + Stop-Process -name "elastic-agent" + + $Job | Wait-Job + + $Result = $Job | Receive-Job + + # The interrupted uninstall should fail with a 1603 + $Result | Should -Be 1603 + + # QUIRK: Clean-up the leftovers as elastic-agent uninstall does not fully rollback during failure + Clean-ElasticAgent + + Check-AgentRemnants + } + + It 'Rollback install when elastic-agent install crashes' { + # Start the MSI in a background job so that we can kill a child process and measure success + $Job = Start-Job -WorkingDirectory $PSScriptRoot -ScriptBlock { + $arglist = "/i $using:PathToLatestMSI /qn INSTALLARGS=""--delay-enroll --url=https://placeholder:443 --enrollment-token=token""" + write-information "msiexec $arglist " + $process = start-process -FilePath "msiexec.exe" -ArgumentList $arglist -wait -passthru + + write-output $process.ExitCode + } + + $Time = 0 + while (-not (get-process "elastic-agent" -erroraction silentlycontinue) -and $Time -lt 45) { + start-sleep -seconds 1 + $Time ++ + } + + $Time | Should -BelessThan 45 -Because "otherwise we timed out waiting for the agent" + + + Stop-Process -name "elastic-agent" + + $Job | Wait-Job + + $Result = $Job | Receive-Job + + # The interrupted install should fail with a 1603 + $Result | Should -Be 1603 + + # QUIRK: Clean-up the leftovers as elastic-agent install does not fully rollback during failure + Clean-ElasticAgentDirectory + + Check-AgentRemnants + } + + It 'Behaves itself as an agent connected to a nonexistent fleet' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + Assert-AgentHealthy + Has-AgentFleetEnrollmentAttempt | Should -BeTrue + + Uninstall-MSI -Path $PathToLatestMSI @MSIUninstallParameters + + Check-AgentRemnants + } + + + It 'Can be installed and uninstalled via MSI, with invalid installargs, in fleet mode' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + Assert-AgentHealthy + + { Uninstall-MSI -Path $PathToLatestMSI -LogToDir $MSIUninstallParameters.LogToDir -Flags 'INSTALLARGS="--delayenroll"' } | Should -Throw + + Uninstall-MSI -Path $PathToLatestMSI @MSIUninstallParameters -Flags 'INSTALLARGS="-v"' + + Check-AgentRemnants + } + + It 'Can be installed in Fleet mode and uninstalled via GUID in default mode' { + Install-MSI -Path $PathToLatestMSI @MSIInstallParameters + + Assert-AgentHealthy + + { Get-AgentUninstallGUID } | Should -Not -Throw + $UninstallGuid = Get-AgentUninstallGUID + + Uninstall-MSI -Guid $UninstallGuid @MSIUninstallParameters -Flags '/norestart' + + Check-AgentRemnants + } + } +} \ No newline at end of file diff --git a/src/build/BullseyeTargets/SignMsiPackageTarget.cs b/src/build/BullseyeTargets/SignMsiPackageTarget.cs index b7804ffc..76dd2e94 100644 --- a/src/build/BullseyeTargets/SignMsiPackageTarget.cs +++ b/src/build/BullseyeTargets/SignMsiPackageTarget.cs @@ -5,7 +5,7 @@ using ElastiBuild.Extensions; using Elastic.Installer; using SimpleExec; - +using Elastic.PackageCompiler; namespace ElastiBuild.BullseyeTargets { @@ -15,9 +15,13 @@ public static async Task RunAsync(BuildContext ctx) { var ap = ctx.GetArtifactPackage(); + // This package name should be aligned with the "out" directory created by + // the BeatPackageCompiler module (as it is the one generating the MSIs to be signed) + string shortPackageName = CmdLineOptions.MakePackageNameShort(ap.CanonicalTargetName); + string filePath = Path.Combine( ctx.OutDir, - ap.CanonicalTargetName, + shortPackageName, Path.GetFileNameWithoutExtension(ap.FileName) + MagicStrings.Ext.DotMsi ); diff --git a/src/build/BullseyeTargets/UnpackPackageTarget.cs b/src/build/BullseyeTargets/UnpackPackageTarget.cs index 29f604dc..92d3f08c 100644 --- a/src/build/BullseyeTargets/UnpackPackageTarget.cs +++ b/src/build/BullseyeTargets/UnpackPackageTarget.cs @@ -45,11 +45,9 @@ public static Task RunAsync(BuildContext ctx) } else { - using var fs = File.Open( - Path.Combine(destDir, fname), - FileMode.Create, - FileAccess.Write); - + var path = Path.Combine(destDir, fname); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + using var fs = File.Open(path, FileMode.Create, FileAccess.Write); itm.Extract(fs); } diff --git a/src/build/Properties/launchSettings.json b/src/build/Properties/launchSettings.json index 48e5eda3..19336124 100644 --- a/src/build/Properties/launchSettings.json +++ b/src/build/Properties/launchSettings.json @@ -14,6 +14,16 @@ "commandName": "Project", "commandLineArgs": "build --cid 8.11.0 filebeat", "workingDirectory": "$(SolutionDir)" + }, + "Elastic Agent 8.13.0": { + "commandName": "Project", + "commandLineArgs": "build --cid 8.13.0-SNAPSHOT elastic-agent --cert-file C:\\staging\\cert\\stack-elastic-installers-sha.pfx --cert-pass C:\\staging\\cert\\stack-elastic-installers-sha-password.txt", + "workingDirectory": "$(SolutionDir)" + }, + "Elastic Agent 8.12.0": { + "commandName": "Project", + "commandLineArgs": "build --cid 8.12.0-SNAPSHOT elastic-agent --cert-file C:\\staging\\cert\\stack-elastic-installers-sha.pfx --cert-pass C:\\staging\\cert\\stack-elastic-installers-sha-password.txt", + "workingDirectory": "$(SolutionDir)" } } } \ No newline at end of file diff --git a/src/config/config.yaml b/src/config/config.yaml index 526fb9fb..9938ffab 100644 --- a/src/config/config.yaml +++ b/src/config/config.yaml @@ -114,3 +114,13 @@ products: published_binaries: [ winlogbeat.exe ] mutable_dirs: [ ] service: true + + elastic-agent: + display_name: Elastic Agent + description: With Elastic Agent you can collect all forms of data from anywhere with a single unified agent per host + published_name: Elastic Agent + published_url: https://www.elastic.co/elastic-agent + published_binaries: [ elastic-agent.exe ] + mutable_dirs: [ ] + is_agent: true + service: false \ No newline at end of file diff --git a/src/installer/BeatPackageCompiler/AgentCustomAction.cs b/src/installer/BeatPackageCompiler/AgentCustomAction.cs new file mode 100644 index 00000000..af9aa95d --- /dev/null +++ b/src/installer/BeatPackageCompiler/AgentCustomAction.cs @@ -0,0 +1,107 @@ +using System; +using System.Diagnostics; +using System.IO; +using Microsoft.Deployment.WindowsInstaller; + +namespace Elastic.PackageCompiler.Beats +{ + public class AgentCustomAction + { + [CustomAction] + public static ActionResult InstallAction(Session session) + { + try + { + string install_args = string.Empty; + + if (!string.IsNullOrEmpty(session["INSTALLARGS"])) + install_args = session["INSTALLARGS"]; + else + session.Log("No INSTALLARGS detected"); + + string install_folder = Path.Combine(session["INSTALLDIR"], session["exe_folder"]); + + System.Diagnostics.Process process = new System.Diagnostics.Process(); + process.StartInfo.FileName = Path.Combine(install_folder, "elastic-agent.exe"); + process.StartInfo.Arguments = "install -f " + install_args; + StartProcess(session, process); + + session.Log("Agent install return code:" + process.ExitCode); + + if (process.ExitCode == 0) + { + // If agent got installed properly, we can go ahead and remove all the files installed by the MSI (best effort) + RemoveFolder(session, install_folder); + } + + return process.ExitCode == 0 ? ActionResult.Success : ActionResult.Failure; + } + catch (Exception ex) + { + session.Log("Exception: " + ex.ToString()); + return ActionResult.Failure; + } + } + + private static void StartProcess(Session session, Process process) + { + // https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.standardoutput?view=net-8.0 + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.CreateNoWindow = true; + session.Log("Running command: " + process.StartInfo.FileName + " " + process.StartInfo.Arguments); + process.Start(); + session.Log("stderr of the process:"); + session.Log(process.StandardError.ReadToEnd()); + process.WaitForExit(); + } + + private static void RemoveFolder(Session session, string folder) + { + try + { + new DirectoryInfo(folder).Delete(true); + session.Log("Successfully removed foler: " + folder); + } + catch (Exception ex) + { + session.Log("Failed to remove folder: " + folder + ", exception: " + ex.ToString()); + } + } + + [CustomAction] + public static ActionResult UpgradeAction(Session session) + { + session.Log("Detected an agent upgrade via MSI, which is not supported. Aborting."); + return ActionResult.Failure; + } + + [CustomAction] + public static ActionResult UnInstallAction(Session session) + { + try + { + string binary_path = @"c:\\Program Files\\Elastic\\Agent\\elastic-agent.exe"; + if (!File.Exists(binary_path)) + { + session.Log("Canont find file: " + binary_path + ", skipping uninstall action"); + return ActionResult.Success; + } + + string install_args = string.IsNullOrEmpty(session["INSTALLARGS"]) ? "" : session["INSTALLARGS"]; + System.Diagnostics.Process process = new System.Diagnostics.Process(); + process.StartInfo.FileName = binary_path; + process.StartInfo.Arguments = "uninstall -f " + install_args; + StartProcess(session, process); + + session.Log("Agent uninstall return code:" + process.ExitCode); + return process.ExitCode == 0 ? ActionResult.Success : ActionResult.Failure; + } + catch (Exception ex) + { + session.Log(ex.ToString()); + return ActionResult.Failure; + } + } + } +} diff --git a/src/installer/BeatPackageCompiler/BeatPackageCompiler.cs b/src/installer/BeatPackageCompiler/BeatPackageCompiler.cs index 3914dd37..74a2dcb5 100644 --- a/src/installer/BeatPackageCompiler/BeatPackageCompiler.cs +++ b/src/installer/BeatPackageCompiler/BeatPackageCompiler.cs @@ -31,7 +31,10 @@ static void Main(string[] args) var companyName = MagicStrings.Elastic; var productSetName = MagicStrings.Beats.Name; - var displayName = MagicStrings.Beats.Name + " " + ap.TargetName; + + // A product can define a display name to be used. + // At the time of writing this line, elastic-agent is the only product that used it + var displayName = !string.IsNullOrEmpty(pc.DisplayName) ? pc.DisplayName : MagicStrings.Beats.Name + " " + ap.TargetName; var exeName = ap.CanonicalTargetName + MagicStrings.Ext.DotExe; // Generate UUID v5 from product properties. @@ -156,7 +159,9 @@ static void Main(string[] args) bool exclude = // .exe must be excluded for service configuration to work (pc.IsWindowsService && itm.EndsWith(exeName, StringComparison.OrdinalIgnoreCase)) - || (isConfigFile); + + // beats config file is handled further down + || (!pc.IsAgent && isConfigFile); // this is an "include" filter return ! exclude; @@ -165,65 +170,43 @@ static void Main(string[] args) packageContents.Add(pc.IsWindowsService ? service : null); - // Add a note to the final screen and a checkbox to open the directory of .example.yml file - var beatConfigExampleFileName = ap.CanonicalTargetName + ".example" + MagicStrings.Ext.DotYml; - var beatConfigExampleFileId = beatConfigExampleFileName + "_" + (uint) beatConfigExampleFileName.GetHashCode32(); - - project.AddProperty(new Property("WIXUI_EXITDIALOGOPTIONALTEXT", - $"NOTE: Only Administrators can modify configuration files! We put an example configuration file " + - $"in the data directory caled {ap.CanonicalTargetName}.example.yml. Please copy this example file to " + - $"{ap.CanonicalTargetName}.yml and make changes according to your environment. Once {ap.CanonicalTargetName}.yml " + - $"is created, you can configure {ap.CanonicalTargetName} from your favorite shell (in an elevated prompt) " + - $"and then start {serviceDisplayName} Windows service.\r\n")); - - project.AddProperty(new Property("WIXUI_EXITDIALOGOPTIONALCHECKBOX", "1")); - project.AddProperty(new Property("WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT", - $"Open {ap.CanonicalTargetName} data directory in Windows Explorer")); + // For agent, the MSI installer copies the contents of the MSI to a temp folder + // and then shall call the 'elastic-agent install' command. + // When uninstalling, the 'elastic-agent uninstall' command. + if (pc.IsAgent) + { + // https://stackoverflow.com/a/311837 + project.LaunchConditions.Add(new LaunchCondition("Privileged", "Elastic Agent MSI must run as an administrator")); + project.AddProperty(new Property("MSIUSEREALADMINDETECTION", "1")); - // We'll open the folder for now - // TODO: select file in explorer window - project.AddProperty(new Property( - "WixShellExecTarget", - $"[$Component.{beatConfigExampleFileId}]")); + // Passing the agent executable path to the action handler which will run it post installation + project.AddProperty(new Property("exe_folder", Path.Combine(ap.Version, ap.CanonicalTargetName))); + project.AddAction(new ManagedAction(AgentCustomAction.InstallAction, Return.check, When.After, Step.InstallExecute, Condition.NOT_Installed)); - project.AddWixFragment("Wix/Product", - XElement.Parse(@" -"), - XElement.Parse(@" - - WIXUI_EXITDIALOGOPTIONALCHECKBOX=1 and NOT Installed - -")); + // https://stackoverflow.com/questions/320921/how-to-add-a-wix-custom-action-that-happens-only-on-uninstall-via-msi + // We invoke the custom action before the "RemoveFiles" step so in case the action fails we can fail the whole MSI uninstall flow + project.AddAction(new ManagedAction(AgentCustomAction.UnInstallAction, Return.check, When.Before, Step.RemoveFiles, Condition.BeingUninstalledAndNotBeingUpgraded)); - var dataContents = new DirectoryInfo(opts.PackageInDir) - .GetFiles(MagicStrings.Files.AllDotYml, SearchOption.TopDirectoryOnly) - .Select(fi => - { - // rename main config file to hide it from MSI engine and keep customizations - if (string.Compare( - fi.Name, - ap.CanonicalTargetName + MagicStrings.Ext.DotYml, - StringComparison.OrdinalIgnoreCase) == 0) - { - var wf = new WixSharp.File(fi.FullName); - wf.Attributes.Add("Name", beatConfigExampleFileName); - wf.Id = new Id(beatConfigExampleFileId); - return wf; - } - return null; - }) - .ToList(); + // Upgrade custom action. Found that "AppSearch" is the first step after WIX_UPGRADE_DETECTED is set + project.AddAction(new ManagedAction(AgentCustomAction.UpgradeAction, Return.check, When.Before, Step.AppSearch, "WIX_UPGRADE_DETECTED AND NOT (REMOVE=\"ALL\")")); + } - packageContents.AddRange(dataContents); + if (!pc.IsAgent) + { + // Add a note to the final screen and a checkbox to open the directory of .example.yml file + var beatConfigExampleFileName = ap.CanonicalTargetName.Replace("-", "_") + ".example" + MagicStrings.Ext.DotYml; + var beatConfigExampleFileId = beatConfigExampleFileName + "_" + (uint) beatConfigExampleFileName.GetHashCode32(); + + project.AddProperty(new Property("WIXUI_EXITDIALOGOPTIONALTEXT", + $"NOTE: Only Administrators can modify configuration files! We put an example configuration file " + + $"in the data directory named {beatConfigExampleFileName}. Please copy this example file to " + + $"{ap.CanonicalTargetName}.yml and make changes according to your environment. Once {ap.CanonicalTargetName}.yml " + + $"is created, you can configure {ap.CanonicalTargetName} from your favorite shell (in an elevated prompt) " + + $"and then start {serviceDisplayName} Windows service.\r\n")); + + HandleOpenExplorer(ap, project, beatConfigExampleFileId); + RenameConfigFile(opts, ap, packageContents, beatConfigExampleFileName, beatConfigExampleFileId); + } // Drop CLI shim on disk var cliShimScriptPath = Path.Combine( @@ -249,11 +232,14 @@ static void Main(string[] args) }; - // CLI Shim path - project.Add(new EnvironmentVariable("PATH", Path.Combine(beatsInstallPath, ap.Version)) + if (!pc.IsAgent) { - Part = EnvVarPart.last - }); + // CLI Shim path (In agent MSI te 'elastic-agent install' takes care of the PATH) + project.Add(new EnvironmentVariable("PATH", Path.Combine(beatsInstallPath, ap.Version)) + { + Part = EnvVarPart.last + }); + } // We hard-link Wix Toolset to a known location Compiler.WixLocation = Path.Combine(opts.BinDir, "WixToolset", "bin"); @@ -280,5 +266,60 @@ static void Main(string[] args) else Compiler.BuildMsi(project); } + + private static void HandleOpenExplorer(ArtifactPackage ap, Project project, string beatConfigExampleFileId) + { + project.AddProperty(new Property("WIXUI_EXITDIALOGOPTIONALCHECKBOX", "1")); + project.AddProperty(new Property("WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT", + $"Open {ap.CanonicalTargetName} data directory in Windows Explorer")); + + // We'll open the folder for now + // TODO: select file in explorer window + project.AddProperty(new Property( + "WixShellExecTarget", + $"[$Component.{beatConfigExampleFileId}]")); + + project.AddWixFragment("Wix/Product", + XElement.Parse(@" +"), + XElement.Parse(@" + + WIXUI_EXITDIALOGOPTIONALCHECKBOX=1 and NOT Installed + +")); + } + + private static void RenameConfigFile(CmdLineOptions opts, ArtifactPackage ap, List packageContents, string beatConfigExampleFileName, string beatConfigExampleFileId) + { + var dataContents = new DirectoryInfo(opts.PackageInDir) + .GetFiles(MagicStrings.Files.AllDotYml, SearchOption.TopDirectoryOnly) + .Select(fi => + { + // rename main config file to hide it from MSI engine and keep customizations + if (string.Compare( + fi.Name, + ap.CanonicalTargetName + MagicStrings.Ext.DotYml, + StringComparison.OrdinalIgnoreCase) == 0) + { + var wf = new WixSharp.File(fi.FullName); + wf.Attributes.Add("Name", beatConfigExampleFileName); + wf.Id = new Id(beatConfigExampleFileId); + return wf; + } + return null; + }) + .ToList(); + + packageContents.AddRange(dataContents); + } } } diff --git a/src/installer/BeatPackageCompiler/Properties/launchSettings.json b/src/installer/BeatPackageCompiler/Properties/launchSettings.json index 56590c03..abd0094c 100644 --- a/src/installer/BeatPackageCompiler/Properties/launchSettings.json +++ b/src/installer/BeatPackageCompiler/Properties/launchSettings.json @@ -9,6 +9,26 @@ "commandName": "Project", "commandLineArgs": "--package=filebeat-8.11.0-windows-x86_64 -v --keep-temp-files", "workingDirectory": "$(SolutionDir)" + }, + "Agent 8.11.1": { + "commandName": "Project", + "commandLineArgs": "--package=elastic-agent-8.11.1-windows-x86_64 -v --keep-temp-files", + "workingDirectory": "$(SolutionDir)" + }, + "Agent 8.10.4": { + "commandName": "Project", + "commandLineArgs": "--package=elastic-agent-8.10.4-windows-x86_64 -v --keep-temp-files", + "workingDirectory": "$(SolutionDir)" + }, + "Agent 8.13.0-SNAPSHOT": { + "commandName": "Project", + "commandLineArgs": "--package=elastic-agent-8.13.0-SNAPSHOT-windows-x86_64 -v --keep-temp-files", + "workingDirectory": "$(SolutionDir)" + }, + "Agent 8.11.4": { + "commandName": "Project", + "commandLineArgs": "--package=elastic-agent-8.11.4-windows-x86_64 -v --keep-temp-files", + "workingDirectory": "$(SolutionDir)" } } } \ No newline at end of file diff --git a/src/installer/resources/elastic-agent.ico b/src/installer/resources/elastic-agent.ico new file mode 100644 index 0000000000000000000000000000000000000000..eea67e4a1b82fc27e84a0d50ee96cfa549b525d4 GIT binary patch literal 106916 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 literal 0 HcmV?d00001 diff --git a/src/shared/ArtifactPackage.cs b/src/shared/ArtifactPackage.cs index 540ecc8d..c7a18395 100644 --- a/src/shared/ArtifactPackage.cs +++ b/src/shared/ArtifactPackage.cs @@ -68,6 +68,12 @@ static bool FromFilenameOrUrl(string fileName, string url, out ArtifactPackage a Architecture = rxGroups["arch"].Value.ToLower(); IsSnapshot = !rxGroups["snapshot"].Value.IsEmpty(); + // HACK + if (TargetName == "agent") + { + TargetName = "elastic-agent"; + } + IsOss = TargetName.EndsWith( MagicStrings.Files.DashOssSuffix, StringComparison.OrdinalIgnoreCase); diff --git a/src/shared/CmdLineOptions.cs b/src/shared/CmdLineOptions.cs index 79ae10be..ba00e84f 100644 --- a/src/shared/CmdLineOptions.cs +++ b/src/shared/CmdLineOptions.cs @@ -23,12 +23,19 @@ public class CmdLineOptions : CommonPathsProvider [Option("keep-temp-files", Default = false, HelpText = "Preserve (do not delete) temporary files")] public bool KeepTempFiles { get; set; } - public string ShortPackageName => - PackageName?.Substring(0, PackageName.IndexOf('-')) ?? string.Empty; + public string ShortPackageName => MakePackageNameShort(PackageName); public string PackageInDir => Path.Combine(InDir, PackageName); public string PackageOutDir => Path.Combine(OutDir, ShortPackageName); + public static string MakePackageNameShort(string packageName) + { + if (packageName.IndexOf('-') > 0) + return packageName.Substring(0, packageName.IndexOf('-')); + else + return packageName; + } + public static CmdLineOptions Parse(string[] args) { using var parser = new Parser(config => diff --git a/src/shared/ProductConfig.cs b/src/shared/ProductConfig.cs index 0c9c42ac..1f6d65a8 100644 --- a/src/shared/ProductConfig.cs +++ b/src/shared/ProductConfig.cs @@ -8,6 +8,12 @@ public sealed class ProductConfig [YamlMember("description")] public string Description { get; set; } = "(add 'description' field to config.yaml)"; + [YamlMember("display_name")] + public string DisplayName { get; set; } = ""; + + [YamlMember("is_agent")] + public bool IsAgent { get; set; } = false; + [YamlMember("published_name")] public string PublishedName { get; set; } = "Elastic Beats";