From 6c771b29def3d36968fb3b354c5f8d66f6ea4338 Mon Sep 17 00:00:00 2001 From: svelderrainruiz Date: Fri, 27 Feb 2026 14:14:12 -0800 Subject: [PATCH] Add host-release 2020 VIPM lifecycle drill lane --- .github/workflows/ci.yml | 1 + ...host-release-2020-vipm-lifecycle-drill.yml | 139 +++++++ ...voke-HostRelease2020VipmLifecycleDrill.ps1 | 353 +++++++++++++++++ scripts/Invoke-VipmInstallUninstallCheck.ps1 | 374 ++++++++++++++++++ ...pmLifecycleDrillWorkflowContract.Tests.ps1 | 92 +++++ 5 files changed, 959 insertions(+) create mode 100644 .github/workflows/host-release-2020-vipm-lifecycle-drill.yml create mode 100644 scripts/Invoke-HostRelease2020VipmLifecycleDrill.ps1 create mode 100644 scripts/Invoke-VipmInstallUninstallCheck.ps1 create mode 100644 tests/HostRelease2020VipmLifecycleDrillWorkflowContract.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a05e6b6..1296cb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,7 @@ jobs: './tests/ReleaseControlPlaneLocalDockerHarnessContract.Tests.ps1', './tests/UploadArtifactRetryCompositeContract.Tests.ps1', './tests/InstallerHarnessWorkflowContract.Tests.ps1', + './tests/HostRelease2020VipmLifecycleDrillWorkflowContract.Tests.ps1', './tests/OpsMonitoringWorkflowContract.Tests.ps1', './tests/OpsIncidentLifecycleContract.Tests.ps1', './tests/OpsAutoRemediationWorkflowContract.Tests.ps1', diff --git a/.github/workflows/host-release-2020-vipm-lifecycle-drill.yml b/.github/workflows/host-release-2020-vipm-lifecycle-drill.yml new file mode 100644 index 0000000..ad2e0bb --- /dev/null +++ b/.github/workflows/host-release-2020-vipm-lifecycle-drill.yml @@ -0,0 +1,139 @@ +name: host-release-2020-vipm-lifecycle-drill + +on: + workflow_dispatch: + inputs: + ref: + description: Optional branch or SHA to evaluate. Defaults to current workflow ref. + required: false + type: string + selected_ppl_bitness: + description: PPL gate bitness to enforce before VIPM lifecycle check (32 or 64). + required: false + default: '64' + type: string + keep_smoke_workspace: + description: Keep smoke workspace on runner for post-mortem troubleshooting. + required: false + default: false + type: boolean + nsis_root: + description: Optional NSIS root override. Defaults to repository variable NSIS_ROOT or C:\Program Files (x86)\NSIS. + required: false + default: '' + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: host-release-2020-vipm-lifecycle-drill-${{ github.ref }} + cancel-in-progress: false + +jobs: + host-release-2020-vipm-lifecycle-drill: + name: Host Release 2020 VIPM Lifecycle Drill + runs-on: [self-hosted, windows, self-hosted-windows-lv, installer-harness] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve workflow_dispatch ref override + if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + & git fetch --prune origin "${{ inputs.ref }}" + if ($LASTEXITCODE -ne 0) { + throw "Failed to fetch requested ref '${{ inputs.ref }}'." + } + + & git checkout --force FETCH_HEAD + if ($LASTEXITCODE -ne 0) { + throw "Failed to checkout requested ref '${{ inputs.ref }}'." + } + + - name: Assert machine preflight pack + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $reportPath = Join-Path $env:RUNNER_TEMP 'host-release-2020-vipm-machine-preflight.json' + & pwsh -NoProfile -File ./scripts/Assert-InstallerHarnessMachinePreflight.ps1 ` + -ExpectedLabviewYear '2020' ` + -DockerContext 'desktop-linux' ` + -DockerCheckSeverity warning ` + -OutputPath $reportPath + if ($LASTEXITCODE -ne 0) { + if (Test-Path -LiteralPath $reportPath -PathType Leaf) { + Get-Content -LiteralPath $reportPath -Raw | Write-Host + } + throw "Machine preflight checks failed." + } + + - name: Execute host-release 2020 VIPM lifecycle drill + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $reportPath = Join-Path $env:RUNNER_TEMP 'host-release-2020-vipm-lifecycle-drill-report.json' + $iterationRoot = Join-Path $env:RUNNER_TEMP 'host-release-2020-vipm-lifecycle' + $smokeRoot = Join-Path $env:RUNNER_TEMP 'dev-smoke-lvie-2020' + + $selectedPplBitnessText = [string]'${{ inputs.selected_ppl_bitness }}' + if ([string]::IsNullOrWhiteSpace($selectedPplBitnessText)) { + $selectedPplBitnessText = '64' + } + if ($selectedPplBitnessText -notin @('32', '64')) { + throw "selected_ppl_bitness must be 32 or 64. actual='$selectedPplBitnessText'" + } + + $keepSmokeWorkspace = $false + $keepSmokeWorkspaceText = [string]'${{ inputs.keep_smoke_workspace }}' + if (-not [string]::IsNullOrWhiteSpace($keepSmokeWorkspaceText)) { + try { + $keepSmokeWorkspace = [System.Convert]::ToBoolean($keepSmokeWorkspaceText) + } catch { + throw "keep_smoke_workspace must be boolean. actual='$keepSmokeWorkspaceText'" + } + } + + $nsisRootInput = [string]'${{ inputs.nsis_root }}' + $nsisRootValue = if (-not [string]::IsNullOrWhiteSpace($nsisRootInput)) { $nsisRootInput } elseif (-not [string]::IsNullOrWhiteSpace([string]::Concat('${{ vars.NSIS_ROOT }}'))) { '${{ vars.NSIS_ROOT }}' } else { 'C:\Program Files (x86)\NSIS' } + + $invokeArgs = @( + '-NoProfile', + '-File', './scripts/Invoke-HostRelease2020VipmLifecycleDrill.ps1', + '-OutputPath', $reportPath, + '-IterationOutputRoot', $iterationRoot, + '-SmokeWorkspaceRoot', $smokeRoot, + '-SelectedPplBitness', $selectedPplBitnessText, + '-TargetLabviewYear', '2020', + '-TargetVipmBitness', '64', + '-NsisRoot', $nsisRootValue + ) + if ($keepSmokeWorkspace) { + $invokeArgs += '-KeepSmokeWorkspace' + } + + & pwsh @invokeArgs + if ($LASTEXITCODE -ne 0) { + if (Test-Path -LiteralPath $reportPath -PathType Leaf) { + Get-Content -LiteralPath $reportPath -Raw | Write-Host + } + throw "Host-release 2020 VIPM lifecycle drill failed." + } + + - name: Upload host-release 2020 VIPM lifecycle artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: host-release-2020-vipm-lifecycle-drill-report-${{ github.run_id }} + path: | + ${{ runner.temp }}/host-release-2020-vipm-lifecycle-drill-report.json + ${{ runner.temp }}/host-release-2020-vipm-machine-preflight.json + ${{ runner.temp }}/host-release-2020-vipm-lifecycle/** + ${{ runner.temp }}/dev-smoke-lvie-2020/artifacts/workspace-install-latest.json + ${{ runner.temp }}/dev-smoke-lvie-2020/labview-icon-editor/builds/status/workspace-installer-vip-build.json + ${{ runner.temp }}/dev-smoke-lvie-2020/labview-icon-editor/builds/logs/vipm-build.log + ${{ runner.temp }}/dev-smoke-lvie-2020/labview-icon-editor/builds/logs/vipm/** + if-no-files-found: error diff --git a/scripts/Invoke-HostRelease2020VipmLifecycleDrill.ps1 b/scripts/Invoke-HostRelease2020VipmLifecycleDrill.ps1 new file mode 100644 index 0000000..3467028 --- /dev/null +++ b/scripts/Invoke-HostRelease2020VipmLifecycleDrill.ps1 @@ -0,0 +1,353 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter()] + [string]$IterationOutputRoot = (Join-Path $env:TEMP 'host-release-2020-vipm-lifecycle'), + + [Parameter()] + [string]$SmokeWorkspaceRoot = 'C:\dev-smoke-lvie-2020', + + [Parameter()] + [ValidateSet('32', '64')] + [string]$SelectedPplBitness = '64', + + [Parameter()] + [ValidatePattern('^\d{4}$')] + [string]$TargetLabviewYear = '2020', + + [Parameter()] + [ValidateSet('32', '64')] + [string]$TargetVipmBitness = '64', + + [Parameter()] + [string]$NsisRoot = 'C:\Program Files (x86)\NSIS', + + [Parameter()] + [switch]$KeepSmokeWorkspace +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$reasonCodeTaxonomy = @( + 'ok', + 'iteration_failed', + 'iteration_summary_missing', + 'exercise_report_missing', + 'smoke_report_missing', + 'smoke_status_failed', + 'smoke_execution_profile_mismatch', + 'ppl_gate_failed', + 'vip_build_not_pass', + 'vip_output_missing', + 'vipm_lifecycle_failed', + 'vip_lifecycle_drill_passed', + 'drill_runtime_error' +) + +function Ensure-ParentDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + + $parent = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent -PathType Container)) { + New-Item -Path $parent -ItemType Directory -Force | Out-Null + } +} + +function Add-PhaseResult { + param( + [Parameter(Mandatory = $true)][AllowEmptyCollection()][System.Collections.Generic.List[object]]$Target, + [Parameter(Mandatory = $true)][string]$Phase, + [Parameter(Mandatory = $true)][ValidateSet('pass', 'fail', 'warn', 'skipped')]$Status, + [Parameter(Mandatory = $true)][string]$ReasonCode, + [Parameter()][string]$Message = '' + ) + + $Target.Add([ordered]@{ + phase = $Phase + status = $Status + reason_code = $ReasonCode + message = $Message + }) | Out-Null +} + +function Throw-DrillError { + param( + [Parameter(Mandatory = $true)][string]$ReasonCode, + [Parameter(Mandatory = $true)][string]$Message + ) + + throw "[reason:$ReasonCode] $Message" +} + +function Resolve-ReasonCodeFromException { + param([Parameter(Mandatory = $true)][string]$Message) + + if ($Message -match '^\[reason:(?[a-z0-9_]+)\]') { + return [string]$Matches.reason + } + + return 'drill_runtime_error' +} + +function Set-TemporaryEnvironmentVariables { + param([Parameter(Mandatory = $true)][hashtable]$Variables) + + $snapshot = @{} + foreach ($name in $Variables.Keys) { + $entry = Get-Item -Path ("Env:{0}" -f $name) -ErrorAction SilentlyContinue + $snapshot[$name] = [pscustomobject]@{ + exists = ($null -ne $entry) + value = if ($null -ne $entry) { [string]$entry.Value } else { '' } + } + + $value = [string]$Variables[$name] + if ([string]::IsNullOrWhiteSpace($value)) { + Remove-Item -Path ("Env:{0}" -f $name) -ErrorAction SilentlyContinue + } else { + Set-Item -Path ("Env:{0}" -f $name) -Value $value + } + } + + return $snapshot +} + +function Restore-TemporaryEnvironmentVariables { + param([Parameter(Mandatory = $true)][hashtable]$Snapshot) + + foreach ($name in $Snapshot.Keys) { + $entry = $Snapshot[$name] + if ([bool]$entry.exists) { + Set-Item -Path ("Env:{0}" -f $name) -Value ([string]$entry.value) + } else { + Remove-Item -Path ("Env:{0}" -f $name) -ErrorAction SilentlyContinue + } + } +} + +function Write-Report { + param( + [Parameter(Mandatory = $true)]$Report, + [Parameter(Mandatory = $true)][string]$Path + ) + + Ensure-ParentDirectory -Path $Path + $Report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $Path -Encoding utf8 +} + +$repoRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..')).Path +$iterationScriptPath = Join-Path $repoRoot 'scripts\Invoke-WorkspaceInstallerIteration.ps1' +$vipmLifecycleScriptPath = Join-Path $repoRoot 'scripts\Invoke-VipmInstallUninstallCheck.ps1' + +$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath) +$resolvedIterationOutputRoot = [System.IO.Path]::GetFullPath($IterationOutputRoot) +$resolvedSmokeWorkspaceRoot = [System.IO.Path]::GetFullPath($SmokeWorkspaceRoot) + +$phaseResults = [System.Collections.Generic.List[object]]::new() +$report = [ordered]@{ + schema_version = '1.0' + generated_at_utc = (Get-Date).ToUniversalTime().ToString('o') + status = 'fail' + reason_code = '' + message = '' + target_labview_year = $TargetLabviewYear + selected_ppl_bitness = $SelectedPplBitness + target_vipm_bitness = $TargetVipmBitness + keep_smoke_workspace = [bool]$KeepSmokeWorkspace + nsis_root = $NsisRoot + phase_results = @() + reason_code_taxonomy = @($reasonCodeTaxonomy) + artifacts = [ordered]@{ + iteration_summary = Join-Path $resolvedIterationOutputRoot 'iteration-summary.json' + exercise_report = '' + smoke_report = '' + vipm_lifecycle_report = Join-Path $resolvedIterationOutputRoot ("vipm-install-uninstall-check.{0}x{1}.json" -f $TargetLabviewYear, $TargetVipmBitness) + vipm_lifecycle_log = Join-Path $resolvedIterationOutputRoot ("vipm-install-uninstall-check.{0}x{1}.log" -f $TargetLabviewYear, $TargetVipmBitness) + vipm_list_before = Join-Path $resolvedIterationOutputRoot ("vipm-list-before-install.{0}x{1}.txt" -f $TargetLabviewYear, $TargetVipmBitness) + vipm_list_after_install = Join-Path $resolvedIterationOutputRoot ("vipm-list-after-install.{0}x{1}.txt" -f $TargetLabviewYear, $TargetVipmBitness) + vipm_list_after_uninstall = Join-Path $resolvedIterationOutputRoot ("vipm-list-after-uninstall.{0}x{1}.txt" -f $TargetLabviewYear, $TargetVipmBitness) + } + details = [ordered]@{ + iteration = [ordered]@{ + output_root = $resolvedIterationOutputRoot + smoke_workspace_root = $resolvedSmokeWorkspaceRoot + script_path = $iterationScriptPath + } + smoke = [ordered]@{} + vipm_lifecycle = [ordered]@{ + script_path = $vipmLifecycleScriptPath + } + failure_message = '' + } +} + +$environmentOverrides = @{ + 'VIPM_COMMUNITY_EDITION' = 'true' + 'LVIE_INSTALLER_EXECUTION_PROFILE' = 'host-release' + 'LVIE_GATE_REQUIRED_LABVIEW_YEAR' = $TargetLabviewYear + 'LVIE_RUNNERCLI_EXECUTION_LABVIEW_YEAR' = $TargetLabviewYear + 'LVIE_GATE_SINGLE_PPL_BITNESS' = $SelectedPplBitness +} +$environmentSnapshot = @{} + +try { + if (-not (Test-Path -LiteralPath $iterationScriptPath -PathType Leaf)) { + Throw-DrillError -ReasonCode 'iteration_failed' -Message ("Iteration runtime is missing: {0}" -f $iterationScriptPath) + } + if (-not (Test-Path -LiteralPath $vipmLifecycleScriptPath -PathType Leaf)) { + Throw-DrillError -ReasonCode 'vipm_lifecycle_failed' -Message ("VIPM lifecycle runtime is missing: {0}" -f $vipmLifecycleScriptPath) + } + + Ensure-ParentDirectory -Path $resolvedOutputPath + if (-not (Test-Path -LiteralPath $resolvedIterationOutputRoot -PathType Container)) { + New-Item -Path $resolvedIterationOutputRoot -ItemType Directory -Force | Out-Null + } + + $environmentSnapshot = Set-TemporaryEnvironmentVariables -Variables $environmentOverrides + Add-PhaseResult -Target $phaseResults -Phase 'preflight' -Status 'pass' -ReasonCode 'ok' + + $iterationArgs = @( + '-NoProfile', + '-File', $iterationScriptPath, + '-Mode', 'full', + '-Iterations', '1', + '-OutputRoot', $resolvedIterationOutputRoot, + '-SmokeWorkspaceRoot', $resolvedSmokeWorkspaceRoot, + '-NsisRoot', $NsisRoot + ) + if ($KeepSmokeWorkspace) { + $iterationArgs += '-KeepSmokeWorkspace' + } + + & pwsh @iterationArgs | Out-Host + $iterationExitCode = if ($null -eq $LASTEXITCODE) { 0 } else { [int]$LASTEXITCODE } + if ($iterationExitCode -ne 0) { + Throw-DrillError -ReasonCode 'iteration_failed' -Message ("Invoke-WorkspaceInstallerIteration.ps1 exited with code {0}." -f $iterationExitCode) + } + Add-PhaseResult -Target $phaseResults -Phase 'iteration' -Status 'pass' -ReasonCode 'ok' + + $iterationSummaryPath = [string]$report.artifacts.iteration_summary + if (-not (Test-Path -LiteralPath $iterationSummaryPath -PathType Leaf)) { + Throw-DrillError -ReasonCode 'iteration_summary_missing' -Message ("Iteration summary is missing: {0}" -f $iterationSummaryPath) + } + + $summary = Get-Content -LiteralPath $iterationSummaryPath -Raw | ConvertFrom-Json -Depth 50 + $runOutputRoot = [string]$summary.latest.output_root + if ([string]::IsNullOrWhiteSpace($runOutputRoot) -or -not (Test-Path -LiteralPath $runOutputRoot -PathType Container)) { + Throw-DrillError -ReasonCode 'exercise_report_missing' -Message ("Latest iteration output_root is missing: {0}" -f $runOutputRoot) + } + + $exerciseReportPath = Join-Path $runOutputRoot 'exercise-report.json' + $report.artifacts.exercise_report = $exerciseReportPath + if (-not (Test-Path -LiteralPath $exerciseReportPath -PathType Leaf)) { + Throw-DrillError -ReasonCode 'exercise_report_missing' -Message ("Exercise report is missing: {0}" -f $exerciseReportPath) + } + + $exerciseReport = Get-Content -LiteralPath $exerciseReportPath -Raw | ConvertFrom-Json -Depth 50 + $smokeReportPath = [string]$exerciseReport.smoke_installer.report_path + if ([string]::IsNullOrWhiteSpace($smokeReportPath)) { + $smokeReportPath = Join-Path $resolvedSmokeWorkspaceRoot 'artifacts\workspace-install-latest.json' + } + $report.artifacts.smoke_report = $smokeReportPath + if (-not (Test-Path -LiteralPath $smokeReportPath -PathType Leaf)) { + Throw-DrillError -ReasonCode 'smoke_report_missing' -Message ("Smoke report is missing: {0}" -f $smokeReportPath) + } + + $smokeReport = Get-Content -LiteralPath $smokeReportPath -Raw | ConvertFrom-Json -Depth 100 + $smokeStatus = [string]$smokeReport.status + $executionProfile = [string]$smokeReport.execution_profile + $selectedPplStatus = '' + if ($null -ne $smokeReport.ppl_capability_checks -and $null -ne $smokeReport.ppl_capability_checks.PSObject.Properties[$SelectedPplBitness]) { + $selectedPplStatus = [string]$smokeReport.ppl_capability_checks.PSObject.Properties[$SelectedPplBitness].Value.status + } + $vipBuildStatus = [string]$smokeReport.vip_package_build_check.status + $vipOutputPath = [string]$smokeReport.vip_package_build_check.output_vip_path + + $report.details.smoke = [ordered]@{ + status = $smokeStatus + execution_profile = $executionProfile + selected_ppl_status = $selectedPplStatus + vip_build_status = $vipBuildStatus + vip_output_path = $vipOutputPath + } + + if ($smokeStatus -ne 'succeeded') { + Throw-DrillError -ReasonCode 'smoke_status_failed' -Message ("Smoke installer report status is not succeeded: {0}" -f $smokeStatus) + } + if ($executionProfile -ne 'host-release') { + Throw-DrillError -ReasonCode 'smoke_execution_profile_mismatch' -Message ("Smoke execution profile is not host-release: {0}" -f $executionProfile) + } + if ($selectedPplStatus -ne 'pass') { + Throw-DrillError -ReasonCode 'ppl_gate_failed' -Message ("PPL gate status for bitness {0} is not pass: {1}" -f $SelectedPplBitness, $selectedPplStatus) + } + if ($vipBuildStatus -ne 'pass') { + Throw-DrillError -ReasonCode 'vip_build_not_pass' -Message ("VIP package build status is not pass: {0}" -f $vipBuildStatus) + } + if ([string]::IsNullOrWhiteSpace($vipOutputPath) -or -not (Test-Path -LiteralPath $vipOutputPath -PathType Leaf)) { + Throw-DrillError -ReasonCode 'vip_output_missing' -Message ("VIP output package is missing: {0}" -f $vipOutputPath) + } + Add-PhaseResult -Target $phaseResults -Phase 'smoke_contract' -Status 'pass' -ReasonCode 'ok' + + $vipmReportPath = [string]$report.artifacts.vipm_lifecycle_report + $vipmArgs = @( + '-NoProfile', + '-File', $vipmLifecycleScriptPath, + '-VipPath', $vipOutputPath, + '-TargetLabviewYear', $TargetLabviewYear, + '-TargetBitness', $TargetVipmBitness, + '-OutputPath', $vipmReportPath, + '-ListBeforePath', ([string]$report.artifacts.vipm_list_before), + '-ListAfterInstallPath', ([string]$report.artifacts.vipm_list_after_install), + '-ListAfterUninstallPath', ([string]$report.artifacts.vipm_list_after_uninstall), + '-LogPath', ([string]$report.artifacts.vipm_lifecycle_log) + ) + + & pwsh @vipmArgs | Out-Host + $vipmExitCode = if ($null -eq $LASTEXITCODE) { 0 } else { [int]$LASTEXITCODE } + if (-not (Test-Path -LiteralPath $vipmReportPath -PathType Leaf)) { + Throw-DrillError -ReasonCode 'vipm_lifecycle_failed' -Message ("VIPM lifecycle report is missing: {0}" -f $vipmReportPath) + } + + $vipmReport = Get-Content -LiteralPath $vipmReportPath -Raw | ConvertFrom-Json -Depth 100 + $report.details.vipm_lifecycle = [ordered]@{ + exit_code = $vipmExitCode + status = [string]$vipmReport.status + reason_code = [string]$vipmReport.reason_code + message = [string]$vipmReport.message + report_path = $vipmReportPath + log_path = [string]$report.artifacts.vipm_lifecycle_log + } + + if ($vipmExitCode -ne 0 -or [string]$vipmReport.status -ne 'pass') { + Throw-DrillError -ReasonCode 'vipm_lifecycle_failed' -Message ("VIPM lifecycle check failed. exit_code={0} reason_code={1}" -f $vipmExitCode, [string]$vipmReport.reason_code) + } + Add-PhaseResult -Target $phaseResults -Phase 'vipm_lifecycle' -Status 'pass' -ReasonCode 'ok' + + $report.status = 'pass' + $report.reason_code = 'vip_lifecycle_drill_passed' + $report.message = ("Host-release LabVIEW {0} VIPM lifecycle drill passed." -f $TargetLabviewYear) +} +catch { + $failureMessage = [string]$_.Exception.Message + $report.status = 'fail' + $report.reason_code = Resolve-ReasonCodeFromException -Message $failureMessage + $report.message = $failureMessage + $report.details.failure_message = $failureMessage + Add-PhaseResult -Target $phaseResults -Phase 'failure' -Status 'fail' -ReasonCode $report.reason_code -Message $failureMessage +} +finally { + if ($environmentSnapshot.Count -gt 0) { + Restore-TemporaryEnvironmentVariables -Snapshot $environmentSnapshot + } + + $report.phase_results = @($phaseResults) + Write-Report -Report $report -Path $resolvedOutputPath +} + +if ([string]$report.status -eq 'fail') { + exit 1 +} + +exit 0 diff --git a/scripts/Invoke-VipmInstallUninstallCheck.ps1 b/scripts/Invoke-VipmInstallUninstallCheck.ps1 new file mode 100644 index 0000000..f91fda9 --- /dev/null +++ b/scripts/Invoke-VipmInstallUninstallCheck.ps1 @@ -0,0 +1,374 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$VipPath, + + [Parameter()] + [ValidatePattern('^\d{4}$')] + [string]$TargetLabviewYear = '2020', + + [Parameter()] + [ValidateSet('32', '64')] + [string]$TargetBitness = '64', + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter()] + [string]$ListBeforePath = '', + + [Parameter()] + [string]$ListAfterInstallPath = '', + + [Parameter()] + [string]$ListAfterUninstallPath = '', + + [Parameter()] + [string]$LogPath = '', + + [Parameter()] + [bool]$AllowLegacyFallback = $true +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$reasonCodeTaxonomy = @( + 'ok', + 'vip_path_missing', + 'target_labview_missing', + 'vipm_cli_missing', + 'vipm_activate_failed', + 'vipm_list_before_failed', + 'vipm_install_failed', + 'vipm_list_after_install_failed', + 'uninstall_target_resolution_failed', + 'vipm_uninstall_failed', + 'vipm_list_after_uninstall_failed', + 'vipm_uninstall_verification_failed', + 'vip_lifecycle_passed', + 'vipm_lifecycle_runtime_error' +) + +function Ensure-ParentDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + + $parent = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent -PathType Container)) { + New-Item -Path $parent -ItemType Directory -Force | Out-Null + } +} + +function Throw-VipmCheckError { + param( + [Parameter(Mandatory = $true)][string]$ReasonCode, + [Parameter(Mandatory = $true)][string]$Message + ) + + throw "[reason:$ReasonCode] $Message" +} + +function Resolve-ReasonCodeFromException { + param([Parameter(Mandatory = $true)][string]$Message) + + if ($Message -match '^\[reason:(?[a-z0-9_]+)\]') { + return [string]$Matches.reason + } + + return 'vipm_lifecycle_runtime_error' +} + +function Resolve-TargetLabviewRoot { + param( + [Parameter(Mandatory = $true)][string]$Year, + [Parameter(Mandatory = $true)][ValidateSet('32', '64')][string]$Bitness + ) + + if ($Bitness -eq '32') { + return "C:\Program Files (x86)\National Instruments\LabVIEW $Year" + } + + return "C:\Program Files\National Instruments\LabVIEW $Year" +} + +function Write-Report { + param( + [Parameter(Mandatory = $true)]$Report, + [Parameter(Mandatory = $true)][string]$Path + ) + + Ensure-ParentDirectory -Path $Path + $Report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $Path -Encoding utf8 +} + +$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath) +$resolvedVipPath = [System.IO.Path]::GetFullPath($VipPath) +$outputRoot = Split-Path -Parent $resolvedOutputPath + +if ([string]::IsNullOrWhiteSpace($ListBeforePath)) { + $ListBeforePath = Join-Path $outputRoot ("vipm-list-before-install.{0}x{1}.txt" -f $TargetLabviewYear, $TargetBitness) +} +if ([string]::IsNullOrWhiteSpace($ListAfterInstallPath)) { + $ListAfterInstallPath = Join-Path $outputRoot ("vipm-list-after-install.{0}x{1}.txt" -f $TargetLabviewYear, $TargetBitness) +} +if ([string]::IsNullOrWhiteSpace($ListAfterUninstallPath)) { + $ListAfterUninstallPath = Join-Path $outputRoot ("vipm-list-after-uninstall.{0}x{1}.txt" -f $TargetLabviewYear, $TargetBitness) +} +if ([string]::IsNullOrWhiteSpace($LogPath)) { + $LogPath = Join-Path $outputRoot ("vipm-install-uninstall-check.{0}x{1}.log" -f $TargetLabviewYear, $TargetBitness) +} + +$resolvedListBeforePath = [System.IO.Path]::GetFullPath($ListBeforePath) +$resolvedListAfterInstallPath = [System.IO.Path]::GetFullPath($ListAfterInstallPath) +$resolvedListAfterUninstallPath = [System.IO.Path]::GetFullPath($ListAfterUninstallPath) +$resolvedLogPath = [System.IO.Path]::GetFullPath($LogPath) + +Ensure-ParentDirectory -Path $resolvedOutputPath +Ensure-ParentDirectory -Path $resolvedListBeforePath +Ensure-ParentDirectory -Path $resolvedListAfterInstallPath +Ensure-ParentDirectory -Path $resolvedListAfterUninstallPath +Ensure-ParentDirectory -Path $resolvedLogPath +if (Test-Path -LiteralPath $resolvedLogPath -PathType Leaf) { + Remove-Item -LiteralPath $resolvedLogPath -Force +} + +function Write-VipmCheckLog { + param([Parameter(Mandatory = $true)][string]$Message) + + $line = "[{0}] {1}" -f ((Get-Date).ToUniversalTime().ToString('o')), $Message + Add-Content -LiteralPath $resolvedLogPath -Value $line -Encoding utf8 + Write-Host $line +} + +function Invoke-VipmScoped { + param( + [Parameter(Mandatory = $true)][string]$VipmExe, + [Parameter(Mandatory = $true)][string[]]$CommandArgs, + [Parameter(Mandatory = $true)][ValidateSet('labview-version', 'labview')][string]$PrimaryOptionsStyle, + [Parameter(Mandatory = $true)][bool]$AllowFallback + ) + + $attempts = @() + if ($PrimaryOptionsStyle -eq 'labview-version') { + $attempts += [ordered]@{ + name = 'labview-version' + prefix = @('--labview-version', $TargetLabviewYear, '--labview-bitness', $TargetBitness) + } + } else { + $attempts += [ordered]@{ + name = 'labview' + prefix = @('--labview', $TargetLabviewYear, '--bitness', $TargetBitness) + } + } + + if ($AllowFallback -and $PrimaryOptionsStyle -eq 'labview-version') { + $attempts += [ordered]@{ + name = 'labview' + prefix = @('--labview', $TargetLabviewYear, '--bitness', $TargetBitness) + } + } + + $lastFailure = $null + foreach ($attempt in @($attempts)) { + $args = @($attempt.prefix + $CommandArgs) + Write-VipmCheckLog ("Executing: {0} {1}" -f $VipmExe, ($args -join ' ')) + $commandOutput = & $VipmExe @args 2>&1 + $exitCode = if ($null -eq $LASTEXITCODE) { 0 } else { [int]$LASTEXITCODE } + + if ($null -ne $commandOutput) { + foreach ($line in @($commandOutput)) { + Add-Content -LiteralPath $resolvedLogPath -Value ([string]$line) -Encoding utf8 + } + } + + if ($exitCode -eq 0) { + return [pscustomobject]@{ + status = 'pass' + exit_code = $exitCode + output = @($commandOutput) + options_style = [string]$attempt.name + } + } + + $outputText = [string]::Join("`n", @($commandOutput | ForEach-Object { [string]$_ })) + if ( + $AllowFallback -and + $attempt.name -eq 'labview-version' -and + $outputText -match '(?i)(unknown|invalid|unrecognized).*(labview-version|labview-bitness|--labview-version|--labview-bitness)' + ) { + Write-VipmCheckLog 'VIPM did not accept --labview-version/--labview-bitness; retrying with --labview/--bitness.' + continue + } + + $lastFailure = [pscustomobject]@{ + status = 'fail' + exit_code = $exitCode + output = @($commandOutput) + options_style = [string]$attempt.name + } + break + } + + if ($null -ne $lastFailure) { + return $lastFailure + } + + return [pscustomobject]@{ + status = 'fail' + exit_code = 1 + output = @('vipm_command_failed_without_output') + options_style = [string]$PrimaryOptionsStyle + } +} + +function Get-VipmInstalledPackages { + param([Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$OutputLines) + + $packages = @() + foreach ($line in @($OutputLines)) { + if ([string]::IsNullOrWhiteSpace([string]$line)) { + continue + } + + $trimmed = ([string]$line).Trim() + $match = [regex]::Match($trimmed, '^(?.+?)\s+\((?[\w\.\-]+)\s+v(?[^)]+)\)$') + if ($match.Success) { + $packages += [pscustomobject]@{ + name = $match.Groups['name'].Value.Trim() + id = $match.Groups['id'].Value.Trim() + version = $match.Groups['version'].Value.Trim() + } + } + } + + return @($packages) +} + +$report = [ordered]@{ + schema_version = '1.0' + generated_at_utc = (Get-Date).ToUniversalTime().ToString('o') + status = 'fail' + reason_code = '' + message = '' + target_labview_year = $TargetLabviewYear + target_bitness = $TargetBitness + vip_path = $resolvedVipPath + vipm_cli_path = '' + options_style = '' + activation_exit_code = $null + uninstall_target = '' + detected_added_packages = @() + paths = [ordered]@{ + output_report = $resolvedOutputPath + log = $resolvedLogPath + list_before = $resolvedListBeforePath + list_after_install = $resolvedListAfterInstallPath + list_after_uninstall = $resolvedListAfterUninstallPath + } + reason_code_taxonomy = @($reasonCodeTaxonomy) +} + +try { + if (-not (Test-Path -LiteralPath $resolvedVipPath -PathType Leaf)) { + Throw-VipmCheckError -ReasonCode 'vip_path_missing' -Message ("VIP package path is missing: {0}" -f $resolvedVipPath) + } + + $targetLabviewRoot = Resolve-TargetLabviewRoot -Year $TargetLabviewYear -Bitness $TargetBitness + if (-not (Test-Path -LiteralPath $targetLabviewRoot -PathType Container)) { + Throw-VipmCheckError -ReasonCode 'target_labview_missing' -Message ("LabVIEW target path is missing: {0}" -f $targetLabviewRoot) + } + + $vipmCommand = Get-Command -Name 'vipm' -ErrorAction SilentlyContinue + if ($null -eq $vipmCommand -or [string]::IsNullOrWhiteSpace([string]$vipmCommand.Source)) { + Throw-VipmCheckError -ReasonCode 'vipm_cli_missing' -Message 'VIPM CLI executable was not found in PATH.' + } + $vipmExe = [string]$vipmCommand.Source + $report.vipm_cli_path = $vipmExe + + $env:VIPM_COMMUNITY_EDITION = 'true' + Write-VipmCheckLog 'Running vipm activate prior to install/uninstall verification.' + $activateOutput = & $vipmExe activate 2>&1 + $activateExitCode = if ($null -eq $LASTEXITCODE) { 0 } else { [int]$LASTEXITCODE } + $report.activation_exit_code = $activateExitCode + if ($null -ne $activateOutput) { + foreach ($line in @($activateOutput)) { + Add-Content -LiteralPath $resolvedLogPath -Value ([string]$line) -Encoding utf8 + } + } + if ($activateExitCode -ne 0) { + Throw-VipmCheckError -ReasonCode 'vipm_activate_failed' -Message ("vipm activate failed with exit code {0}." -f $activateExitCode) + } + + Write-VipmCheckLog 'Capturing installed package list before install.' + $listBefore = Invoke-VipmScoped -VipmExe $vipmExe -CommandArgs @('list', '--installed') -PrimaryOptionsStyle 'labview-version' -AllowFallback $AllowLegacyFallback + if ([string]$listBefore.status -ne 'pass') { + Throw-VipmCheckError -ReasonCode 'vipm_list_before_failed' -Message ("vipm list --installed (before install) failed with exit code {0}." -f [int]$listBefore.exit_code) + } + $report.options_style = [string]$listBefore.options_style + @($listBefore.output) | Set-Content -LiteralPath $resolvedListBeforePath -Encoding utf8 + $beforePackages = Get-VipmInstalledPackages -OutputLines @($listBefore.output | ForEach-Object { [string]$_ }) + + Write-VipmCheckLog ("Installing VIP package: {0}" -f $resolvedVipPath) + $installResult = Invoke-VipmScoped -VipmExe $vipmExe -CommandArgs @('install', $resolvedVipPath) -PrimaryOptionsStyle $report.options_style -AllowFallback:$false + if ([string]$installResult.status -ne 'pass') { + Throw-VipmCheckError -ReasonCode 'vipm_install_failed' -Message ("vipm install failed with exit code {0}." -f [int]$installResult.exit_code) + } + + Write-VipmCheckLog 'Capturing installed package list after install.' + $listAfterInstall = Invoke-VipmScoped -VipmExe $vipmExe -CommandArgs @('list', '--installed') -PrimaryOptionsStyle $report.options_style -AllowFallback:$false + if ([string]$listAfterInstall.status -ne 'pass') { + Throw-VipmCheckError -ReasonCode 'vipm_list_after_install_failed' -Message ("vipm list --installed (after install) failed with exit code {0}." -f [int]$listAfterInstall.exit_code) + } + @($listAfterInstall.output) | Set-Content -LiteralPath $resolvedListAfterInstallPath -Encoding utf8 + $afterInstallPackages = Get-VipmInstalledPackages -OutputLines @($listAfterInstall.output | ForEach-Object { [string]$_ }) + + $beforeById = @{} + foreach ($package in @($beforePackages)) { + $beforeById[[string]$package.id] = $package + } + $addedPackages = @($afterInstallPackages | Where-Object { -not $beforeById.ContainsKey([string]$_.id) }) + $report.detected_added_packages = @($addedPackages | ForEach-Object { [string]$_.id }) + if (@($addedPackages).Count -ne 1) { + Throw-VipmCheckError -ReasonCode 'uninstall_target_resolution_failed' -Message ("Deterministic uninstall target resolution failed. expected_added_count=1 actual_added_count={0}" -f @($addedPackages).Count) + } + $uninstallTarget = [string]$addedPackages[0].id + $report.uninstall_target = $uninstallTarget + + Write-VipmCheckLog ("Uninstalling package id: {0}" -f $uninstallTarget) + $uninstallResult = Invoke-VipmScoped -VipmExe $vipmExe -CommandArgs @('uninstall', $uninstallTarget) -PrimaryOptionsStyle $report.options_style -AllowFallback:$false + if ([string]$uninstallResult.status -ne 'pass') { + Throw-VipmCheckError -ReasonCode 'vipm_uninstall_failed' -Message ("vipm uninstall failed with exit code {0}." -f [int]$uninstallResult.exit_code) + } + + Write-VipmCheckLog 'Capturing installed package list after uninstall.' + $listAfterUninstall = Invoke-VipmScoped -VipmExe $vipmExe -CommandArgs @('list', '--installed') -PrimaryOptionsStyle $report.options_style -AllowFallback:$false + if ([string]$listAfterUninstall.status -ne 'pass') { + Throw-VipmCheckError -ReasonCode 'vipm_list_after_uninstall_failed' -Message ("vipm list --installed (after uninstall) failed with exit code {0}." -f [int]$listAfterUninstall.exit_code) + } + @($listAfterUninstall.output) | Set-Content -LiteralPath $resolvedListAfterUninstallPath -Encoding utf8 + $afterUninstallPackages = Get-VipmInstalledPackages -OutputLines @($listAfterUninstall.output | ForEach-Object { [string]$_ }) + + $targetStillInstalled = @($afterUninstallPackages | Where-Object { [string]$_.id -eq $uninstallTarget }) + if (@($targetStillInstalled).Count -gt 0) { + Throw-VipmCheckError -ReasonCode 'vipm_uninstall_verification_failed' -Message ("Package id '{0}' is still installed after uninstall." -f $uninstallTarget) + } + + $report.status = 'pass' + $report.reason_code = 'vip_lifecycle_passed' + $report.message = ("VIP install/uninstall verification succeeded for LabVIEW {0} {1}-bit." -f $TargetLabviewYear, $TargetBitness) +} +catch { + $failureMessage = [string]$_.Exception.Message + $report.status = 'fail' + $report.reason_code = Resolve-ReasonCodeFromException -Message $failureMessage + $report.message = $failureMessage +} +finally { + Write-Report -Report $report -Path $resolvedOutputPath +} + +if ([string]$report.status -eq 'fail') { + exit 1 +} + +exit 0 diff --git a/tests/HostRelease2020VipmLifecycleDrillWorkflowContract.Tests.ps1 b/tests/HostRelease2020VipmLifecycleDrillWorkflowContract.Tests.ps1 new file mode 100644 index 0000000..4bf0757 --- /dev/null +++ b/tests/HostRelease2020VipmLifecycleDrillWorkflowContract.Tests.ps1 @@ -0,0 +1,92 @@ +#Requires -Version 7.0 +#Requires -Modules Pester + +$ErrorActionPreference = 'Stop' + +Describe 'Host release 2020 VIPM lifecycle drill workflow contract' { + BeforeAll { + $script:repoRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..')).Path + $script:workflowPath = Join-Path $script:repoRoot '.github/workflows/host-release-2020-vipm-lifecycle-drill.yml' + $script:drillRuntimePath = Join-Path $script:repoRoot 'scripts/Invoke-HostRelease2020VipmLifecycleDrill.ps1' + $script:vipmRuntimePath = Join-Path $script:repoRoot 'scripts/Invoke-VipmInstallUninstallCheck.ps1' + + foreach ($path in @($script:workflowPath, $script:drillRuntimePath, $script:vipmRuntimePath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Host release 2020 VIPM lifecycle contract file missing: $path" + } + } + + $script:workflowContent = Get-Content -LiteralPath $script:workflowPath -Raw + $script:drillRuntimeContent = Get-Content -LiteralPath $script:drillRuntimePath -Raw + $script:vipmRuntimeContent = Get-Content -LiteralPath $script:vipmRuntimePath -Raw + } + + It 'is dispatchable with ref, bitness, workspace retention, and NSIS override inputs' { + $script:workflowContent | Should -Match 'workflow_dispatch:' + $script:workflowContent | Should -Match 'ref:' + $script:workflowContent | Should -Match 'selected_ppl_bitness:' + $script:workflowContent | Should -Match 'keep_smoke_workspace:' + $script:workflowContent | Should -Match 'nsis_root:' + } + + It 'runs on installer-harness self-hosted windows labels and publishes deterministic artifacts' { + $script:workflowContent | Should -Match 'runs-on:\s*\[self-hosted,\s*windows,\s*self-hosted-windows-lv,\s*installer-harness\]' + $script:workflowContent | Should -Match 'Assert-InstallerHarnessMachinePreflight\.ps1' + $script:workflowContent | Should -Match "ExpectedLabviewYear '2020'" + $script:workflowContent | Should -Match 'Invoke-HostRelease2020VipmLifecycleDrill\.ps1' + $script:workflowContent | Should -Match "TargetLabviewYear', '2020'" + $script:workflowContent | Should -Match 'host-release-2020-vipm-lifecycle-drill-report-\$\{\{\s*github\.run_id\s*\}\}' + $script:workflowContent | Should -Match 'host-release-2020-vipm-lifecycle-drill-report\.json' + } + + It 'orchestrates host-release installer iteration and deterministic VIPM lifecycle checks' { + foreach ($token in @( + 'reasonCodeTaxonomy', + 'LVIE_INSTALLER_EXECUTION_PROFILE', + 'host-release', + 'LVIE_RUNNERCLI_EXECUTION_LABVIEW_YEAR', + 'Invoke-WorkspaceInstallerIteration\.ps1', + 'Invoke-VipmInstallUninstallCheck\.ps1', + 'vipm_lifecycle_failed', + 'vip_lifecycle_drill_passed' + )) { + $script:drillRuntimeContent | Should -Match $token + } + } + + It 'implements deterministic VIPM install/uninstall reason codes and command flows' { + foreach ($token in @( + 'vip_path_missing', + 'target_labview_missing', + 'vipm_cli_missing', + 'vipm_activate_failed', + 'vipm_list_before_failed', + 'vipm_install_failed', + 'vipm_list_after_install_failed', + 'uninstall_target_resolution_failed', + 'vipm_uninstall_failed', + 'vipm_list_after_uninstall_failed', + 'vipm_uninstall_verification_failed', + 'vip_lifecycle_passed', + 'vipm_lifecycle_runtime_error', + 'vipm activate', + '--labview-version', + '--labview-bitness', + '--labview', + '--bitness', + '''install'',', + '''uninstall'',' + )) { + $script:vipmRuntimeContent | Should -Match $token + } + } + + It 'has parse-safe PowerShell syntax for new runtimes' { + foreach ($content in @($script:drillRuntimeContent, $script:vipmRuntimeContent)) { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$tokens, [ref]$errors) + @($errors).Count | Should -Be 0 + } + } +}