1 # (c) 2019 Ansible Project
2 # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
3 
4 param(
5     [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Payload
6 )
7 
8 #AnsibleRequires -Wrapper module_wrapper
9 
10 $ErrorActionPreference = "Stop"
11 
12 Write-AnsibleLog "INFO - starting coverage_wrapper" "coverage_wrapper"
13 
14 # Required to be set for psrp to we can set a breakpoint in the remote runspace
15 if ($PSVersionTable.PSVersion -ge [Version]'4.0') {
16     $host.Runspace.Debugger.SetDebugMode([System.Management.Automation.DebugModes]::RemoteScript)
17 }
18 
New-CoverageBreakpointnull19 Function New-CoverageBreakpoint {
20     Param (
21         [String]$Path,
22         [ScriptBlock]$Code,
23         [String]$AnsiblePath
24     )
25 
26     # It is quicker to pass in the code as a string instead of calling ParseFile as we already know the contents
27     $predicate = {
28         $args[0] -is [System.Management.Automation.Language.CommandBaseAst]
29     }
30     $script_cmds = $Code.Ast.FindAll($predicate, $true)
31 
32     # Create an object that tracks the Ansible path of the file and the breakpoints that have been set in it
33     $info = [PSCustomObject]@{
34         Path = $AnsiblePath
35         Breakpoints = [System.Collections.Generic.List`1[System.Management.Automation.Breakpoint]]@()
36     }
37 
38     # Keep track of lines that are already scanned. PowerShell can contains multiple commands in 1 line
39     $scanned_lines = [System.Collections.Generic.HashSet`1[System.Int32]]@()
40     foreach ($cmd in $script_cmds) {
41         if (-not $scanned_lines.Add($cmd.Extent.StartLineNumber)) {
42             continue
43         }
44 
45         # Do not add any -Action value, even if it is $null or {}. Doing so will balloon the runtime.
46         $params = @{
47             Script = $Path
48             Line = $cmd.Extent.StartLineNumber
49             Column = $cmd.Extent.StartColumnNumber
50         }
51         $info.Breakpoints.Add((Set-PSBreakpoint @params))
52     }
53 
54     $info
55 }
56 
Compare-WhitelistPattern()57 Function Compare-WhitelistPattern {
58     Param (
59         [String[]]$Patterns,
60         [String]$Path
61     )
62 
63     foreach ($pattern in $Patterns) {
64         if ($Path -like $pattern) {
65             return $true
66         }
67     }
68     return $false
69 }
70 
71 $module_name = $Payload.module_args["_ansible_module_name"]
72 Write-AnsibleLog "INFO - building coverage payload for '$module_name'" "coverage_wrapper"
73 
74 # A PS Breakpoint needs an actual path to work properly, we create a temp directory that will store the module and
75 # module_util code during execution
76 $temp_path = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "ansible-coverage-$([System.IO.Path]::GetRandomFileName())"
77 Write-AnsibleLog "INFO - Creating temp path for coverage files '$temp_path'" "coverage_wrapper"
78 New-Item -Path $temp_path -ItemType Directory > $null
79 $breakpoint_info = [System.Collections.Generic.List`1[PSObject]]@()
80 
81 # Ensures we create files with UTF-8 encoding and a BOM. This is critical to force the powershell engine to read files
82 # as UTF-8 and not as the system's codepage.
83 $file_encoding = 'UTF8'
84 
85 try {
86     $scripts = [System.Collections.Generic.List`1[System.Object]]@($script:common_functions)
87 
88     $coverage_whitelist = $Payload.coverage.whitelist.Split(":", [StringSplitOptions]::RemoveEmptyEntries)
89 
90     # We need to track what utils have already been added to the script for loading. This is because the load
91     # order is important and can have module_utils that rely on other utils.
92     $loaded_utils = [System.Collections.Generic.HashSet`1[System.String]]@()
93     $parse_util = {
94         $util_name = $args[0]
95         if (-not $loaded_utils.Add($util_name)) {
96             return
97         }
98 
99         $util_code = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Payload.powershell_modules.$util_name))
100         $util_sb = [ScriptBlock]::Create($util_code)
101         $util_path = Join-Path -Path $temp_path -ChildPath "$($util_name).psm1"
102 
103         Write-AnsibleLog "INFO - Outputting module_util $util_name to temp file '$util_path'" "coverage_wrapper"
104         Set-Content -LiteralPath $util_path -Value $util_code -Encoding $file_encoding
105 
106         $ansible_path = $Payload.coverage.module_util_paths.$util_name
107         if ((Compare-WhitelistPattern -Patterns $coverage_whitelist -Path $ansible_path)) {
108             $cov_params = @{
109                 Path = $util_path
110                 Code = $util_sb
111                 AnsiblePath = $ansible_path
112             }
113             $breakpoints = New-CoverageBreakpoint @cov_params
114             $breakpoint_info.Add($breakpoints)
115         }
116 
117         if ($null -ne $util_sb.Ast.ScriptRequirements) {
118             foreach ($required_util in $util_sb.Ast.ScriptRequirements.RequiredModules) {
119                 &$parse_util $required_util.Name
120             }
121         }
122         Write-AnsibleLog "INFO - Adding util $util_name to scripts to run" "coverage_wrapper"
123         $scripts.Add("Import-Module -Name '$util_path'")
124     }
125     foreach ($util in $Payload.powershell_modules.Keys) {
126         &$parse_util $util
127     }
128 
129     $module = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Payload.module_entry))
130     $module_path = Join-Path -Path $temp_path -ChildPath "$($module_name).ps1"
131     Write-AnsibleLog "INFO - Ouputting module $module_name to temp file '$module_path'" "coverage_wrapper"
132     Set-Content -LiteralPath $module_path -Value $module -Encoding $file_encoding
133     $scripts.Add($module_path)
134 
135     $ansible_path = $Payload.coverage.module_path
136     if ((Compare-WhitelistPattern -Patterns $coverage_whitelist -Path $ansible_path)) {
137         $cov_params = @{
138             Path = $module_path
139             Code = [ScriptBlock]::Create($module)
140             AnsiblePath = $Payload.coverage.module_path
141         }
142         $breakpoints = New-CoverageBreakpoint @cov_params
143         $breakpoint_info.Add($breakpoints)
144     }
145 
146     $variables = [System.Collections.ArrayList]@(@{ Name = "complex_args"; Value = $Payload.module_args; Scope = "Global" })
147     $entrypoint = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload.module_wrapper))
148     $entrypoint = [ScriptBlock]::Create($entrypoint)
149 
150     $params = @{
151         Scripts = $scripts
152         Variables = $variables
153         Environment = $Payload.environment
154         ModuleName = $module_name
155     }
156     if ($breakpoint_info) {
157         $params.Breakpoints = $breakpoint_info.Breakpoints
158     }
159 
160     try {
161         &$entrypoint @params
162     } finally {
163         # Processing here is kept to an absolute minimum to make sure each task runtime is kept as small as
164         # possible. Once all the tests have been run ansible-test will collect this info and process it locally in
165         # one go.
166         Write-AnsibleLog "INFO - Creating coverage result output" "coverage_wrapper"
167         $coverage_info = @{}
168         foreach ($info in $breakpoint_info) {
169             $coverage_info.($info.Path) = $info.Breakpoints | Select-Object -Property Line, HitCount
170         }
171 
172         # The coverage.output value is a filename set by the Ansible controller. We append some more remote side
173         # info to the filename to make it unique and identify the remote host a bit more.
174         $ps_version = "$($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)"
175         $coverage_output_path = "$($Payload.coverage.output)=powershell-$ps_version=coverage.$($env:COMPUTERNAME).$PID.$(Get-Random)"
176         $code_cov_json = ConvertTo-Json -InputObject $coverage_info -Compress
177 
178         Write-AnsibleLog "INFO - Outputting coverage json to '$coverage_output_path'" "coverage_wrapper"
179         # Ansible controller expects these files to be UTF-8 without a BOM, use .NET for this.
180         $utf8_no_bom = New-Object -TypeName System.Text.UTF8Encoding -ArgumentList $false
181         [System.IO.File]::WriteAllbytes($coverage_output_path, $utf8_no_bom.GetBytes($code_cov_json))
182     }
183 } finally {
184     try {
185         if ($breakpoint_info) {
186             foreach ($b in $breakpoint_info.Breakpoints) {
187                 Remove-PSBreakpoint -Breakpoint $b
188             }
189         }
190     } finally {
191         Write-AnsibleLog "INFO - Remove temp coverage folder '$temp_path'" "coverage_wrapper"
192         Remove-Item -LiteralPath $temp_path -Force -Recurse
193     }
194 }
195 
196 Write-AnsibleLog "INFO - ending coverage_wrapper" "coverage_wrapper"
197