Add arg and doc validation for PowerShell modules (#53615)

* Add arg and doc validation for PowerShell modules

* Verify if pwsh exists before running it
This commit is contained in:
Jordan Borean 2019-03-12 07:56:51 +10:00 committed by GitHub
parent da9b19cef7
commit f297229b52
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 159 additions and 11 deletions

View file

@ -24,7 +24,7 @@ $spec = @{
ignore_dependencies = @{ type = "bool"; default = $false } ignore_dependencies = @{ type = "bool"; default = $false }
force = @{ type = "bool"; default = $false } force = @{ type = "bool"; default = $false }
name = @{ type = "list"; elements = "str"; required = $true } name = @{ type = "list"; elements = "str"; required = $true }
package_params = @{ type = "str"; aliases = "params" } package_params = @{ type = "str"; aliases = @("params") }
pinned = @{ type = "bool" } pinned = @{ type = "bool" }
proxy_url = @{ type = "str" } proxy_url = @{ type = "str" }
proxy_username = @{ type = "str" } proxy_username = @{ type = "str" }
@ -34,7 +34,7 @@ $spec = @{
source_username = @{ type = "str" } source_username = @{ type = "str" }
source_password = @{ type = "str"; no_log = $true } source_password = @{ type = "str"; no_log = $true }
state = @{ type = "str"; default = "present"; choices = "absent", "downgrade", "latest", "present", "reinstalled" } state = @{ type = "str"; default = "present"; choices = "absent", "downgrade", "latest", "present", "reinstalled" }
timeout = @{ type = "int"; default = 2700; aliases = "execution_timeout" } timeout = @{ type = "int"; default = 2700; aliases = @("execution_timeout") }
validate_certs = @{ type = "bool"; default = $true } validate_certs = @{ type = "bool"; default = $true }
version = @{ type = "str" } version = @{ type = "str" }
} }

View file

@ -90,7 +90,7 @@ options:
- md5 - md5
- sha1 - sha1
- sha256 - sha256
- sha385 - sha384
- sha512 - sha512
default: sha1 default: sha1
version_added: "2.8" version_added: "2.8"

View file

@ -29,6 +29,10 @@ options:
- The location of the PsExec utility (in case it is not located in your PATH). - The location of the PsExec utility (in case it is not located in your PATH).
type: path type: path
default: psexec.exe default: psexec.exe
extra_opts:
description:
- Specify additional options to add onto the PsExec invocation.
type: list
hostnames: hostnames:
description: description:
- The hostnames to run the command. - The hostnames to run the command.

View file

@ -38,7 +38,6 @@ options:
- If the requested voice is not available the default voice will be used. - If the requested voice is not available the default voice will be used.
Example voice names from Windows 10 are C(Microsoft Zira Desktop) and C(Microsoft Hazel Desktop). Example voice names from Windows 10 are C(Microsoft Zira Desktop) and C(Microsoft Hazel Desktop).
type: str type: str
default: system default voice
speech_speed: speech_speed:
description: description:
- How fast or slow to speak the text. - How fast or slow to speak the text.

View file

@ -26,6 +26,7 @@ options:
back slashes are accepted. back slashes are accepted.
type: path type: path
required: yes required: yes
aliases: [ dest, name ]
get_md5: get_md5:
description: description:
- Whether to return the checksum sum of the file. Between Ansible 1.9 - Whether to return the checksum sum of the file. Between Ansible 1.9

View file

@ -29,6 +29,7 @@ options:
- If path is not specified default system temporary directory (%TEMP%) will be used. - If path is not specified default system temporary directory (%TEMP%) will be used.
type: path type: path
default: '%TEMP%' default: '%TEMP%'
aliases: [ dest ]
prefix: prefix:
description: description:
- Prefix of file/directory name created by module. - Prefix of file/directory name created by module.

View file

@ -91,7 +91,7 @@ options:
- A valid, numeric, HTTP status code that signifies success of the request. - A valid, numeric, HTTP status code that signifies success of the request.
- Can also be comma separated list of status codes. - Can also be comma separated list of status codes.
type: list type: list
default: 200 default: [ 200 ]
version_added: '2.4' version_added: '2.4'
timeout: timeout:
description: description:
@ -123,7 +123,7 @@ options:
or C(follow_redirects) is set to C(none), or C(follow_redirects) is set to C(none),
or set to C(safe) when not doing C(GET) or C(HEAD) it prevents all redirection. or set to C(safe) when not doing C(GET) or C(HEAD) it prevents all redirection.
type: int type: int
default: 5 default: 50
version_added: '2.4' version_added: '2.4'
validate_certs: validate_certs:
description: description:

View file

@ -23,7 +23,7 @@ options:
process_name_exact: process_name_exact:
description: description:
- The name of the process(es) for which to wait. - The name of the process(es) for which to wait.
type: str type: list
process_name_pattern: process_name_pattern:
description: description:
- RegEx pattern matching desired process(es). - RegEx pattern matching desired process(es).

View file

@ -230,6 +230,9 @@ class ModuleValidator(Validator):
'slurp.ps1', 'slurp.ps1',
'setup.ps1' 'setup.ps1'
)) ))
PS_ARG_VALIDATE_BLACKLIST = frozenset((
'win_dsc.ps1', # win_dsc is a dynamic arg spec, the docs won't ever match
))
WHITELIST_FUTURE_IMPORTS = frozenset(('absolute_import', 'division', 'print_function')) WHITELIST_FUTURE_IMPORTS = frozenset(('absolute_import', 'division', 'print_function'))
@ -773,6 +776,7 @@ class ModuleValidator(Validator):
code=503, code=503,
msg='Missing python documentation file' msg='Missing python documentation file'
) )
return py_path
def _get_docs(self): def _get_docs(self):
docs = { docs = {
@ -1531,7 +1535,14 @@ class ModuleValidator(Validator):
if self._powershell_module(): if self._powershell_module():
self._validate_ps_replacers() self._validate_ps_replacers()
self._find_ps_docs_py_file() docs_path = self._find_ps_docs_py_file()
# We can only validate PowerShell arg spec if it is using the new Ansible.Basic.AnsibleModule util
pattern = r'(?im)^#\s*ansiblerequires\s+\-csharputil\s*Ansible\.Basic'
if re.search(pattern, self.text) and self.object_name not in self.PS_ARG_VALIDATE_BLACKLIST:
with ModuleValidator(docs_path, base_branch=self.base_branch, git_cache=self.git_cache) as docs_mv:
docs = docs_mv._validate_docs()[1]
self._validate_ansible_module_call(docs)
self._check_gpl3_header() self._check_gpl3_header()
if not self._just_docs() and not end_of_deprecation_should_be_removed_only: if not self._just_docs() and not end_of_deprecation_should_be_removed_only:

View file

@ -17,12 +17,17 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import imp import imp
import json
import os
import subprocess
import sys import sys
from contextlib import contextmanager from contextlib import contextmanager
from ansible.module_utils.six import reraise from ansible.module_utils.six import reraise
from utils import find_executable
class AnsibleModuleCallError(RuntimeError): class AnsibleModuleCallError(RuntimeError):
pass pass
@ -75,7 +80,29 @@ def setup_env(filename):
del sys.modules[k] del sys.modules[k]
def get_argument_spec(filename): def get_ps_argument_spec(filename):
# This uses a very small skeleton of Ansible.Basic.AnsibleModule to return the argspec defined by the module. This
# is pretty rudimentary and will probably require something better going forward.
pwsh = find_executable('pwsh')
if not pwsh:
raise FileNotFoundError('Required program for PowerShell arg spec inspection "pwsh" not found.')
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'ps_argspec.ps1')
proc = subprocess.Popen([script_path, filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False)
stdout, stderr = proc.communicate()
if proc.returncode != 0:
raise AnsibleModuleImportError(stderr.decode('utf-8'))
kwargs = json.loads(stdout)
# the validate-modules code expects the options spec to be under the argument_spec key not options as set in PS
kwargs['argument_spec'] = kwargs.pop('options', {})
return kwargs['argument_spec'], (), kwargs
def get_py_argument_spec(filename):
with setup_env(filename) as fake: with setup_env(filename) as fake:
try: try:
# We use ``module`` here instead of ``__main__`` # We use ``module`` here instead of ``__main__``
@ -91,8 +118,16 @@ def get_argument_spec(filename):
try: try:
try: try:
# for ping kwargs == {'argument_spec':{'data':{'type':'str','default':'pong'}}, 'supports_check_mode':True}
return fake.kwargs['argument_spec'], fake.args, fake.kwargs return fake.kwargs['argument_spec'], fake.args, fake.kwargs
except KeyError: except KeyError:
return fake.args[0], fake.args, fake.kwargs return fake.args[0], fake.args, fake.kwargs
except TypeError: except TypeError:
return {}, (), {} return {}, (), {}
def get_argument_spec(filename):
if filename.endswith('.py'):
return get_py_argument_spec(filename)
else:
return get_ps_argument_spec(filename)

View file

@ -0,0 +1,58 @@
#!/usr/bin/env pwsh
#Requires -Version 6
Set-StrictMode -Version 2.0
$ErrorActionPreference = "Stop"
$WarningPreference = "Stop"
$module_path = $args[0]
if (-not $module_path) {
Write-Error -Message "No module specified."
exit 1
}
# Check if the path is relative and get the full path to the module
if (-not ([System.IO.Path]::IsPathRooted($module_path))) {
$module_path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($module_path)
}
if (-not (Test-Path -LiteralPath $module_path -PathType Leaf)) {
Write-Error -Message "The module at '$module_path' does not exist."
exit 1
}
$dummy_ansible_basic = @'
using System;
using System.Collections;
using System.Management.Automation;
namespace Ansible.Basic
{
public class AnsibleModule
{
public AnsibleModule(string[] args, IDictionary argumentSpec)
{
PSObject rawOut = ScriptBlock.Create("ConvertTo-Json -InputObject $args[0] -Depth 99 -Compress").Invoke(argumentSpec)[0];
Console.WriteLine(rawOut.BaseObject.ToString());
ScriptBlock.Create("Set-Variable -Name LASTEXITCODE -Value 0 -Scope Global; exit 0").Invoke();
}
public static AnsibleModule Create(string[] args, IDictionary argumentSpec)
{
return new AnsibleModule(args, argumentSpec);
}
}
}
'@
Add-Type -TypeDefinition $dummy_ansible_basic
$module_code = Get-Content -LiteralPath $module_path -Raw
$powershell = [PowerShell]::Create()
$powershell.AddScript($module_code) > $null
$powershell.Invoke() > $null
if ($powershell.HadErrors) {
$powershell.Streams.Error
exit 1
}

View file

@ -81,7 +81,7 @@ suboption_schema = Schema(
'version_added': Any(float, *string_types), 'version_added': Any(float, *string_types),
'default': Any(None, float, int, bool, list, dict, *string_types), 'default': Any(None, float, int, bool, list, dict, *string_types),
# Note: Types are strings, not literal bools, such as True or False # Note: Types are strings, not literal bools, such as True or False
'type': Any(None, 'bits', 'bool', 'bytes', 'dict', 'float', 'int', 'json', 'jsonarg', 'list', 'path', 'raw', 'str'), 'type': Any(None, 'bits', 'bool', 'bytes', 'dict', 'float', 'int', 'json', 'jsonarg', 'list', 'path', 'raw', 'sid', 'str'),
# Recursive suboptions # Recursive suboptions
'suboptions': Any(None, *list({str_type: Self} for str_type in string_types)), 'suboptions': Any(None, *list({str_type: Self} for str_type in string_types)),
}, },
@ -102,7 +102,7 @@ option_schema = Schema(
'default': Any(None, float, int, bool, list, dict, *string_types), 'default': Any(None, float, int, bool, list, dict, *string_types),
'suboptions': Any(None, *list_dict_suboption_schema), 'suboptions': Any(None, *list_dict_suboption_schema),
# Note: Types are strings, not literal bools, such as True or False # Note: Types are strings, not literal bools, such as True or False
'type': Any(None, 'bits', 'bool', 'bytes', 'dict', 'float', 'int', 'json', 'jsonarg', 'list', 'path', 'raw', 'str'), 'type': Any(None, 'bits', 'bool', 'bytes', 'dict', 'float', 'int', 'json', 'jsonarg', 'list', 'path', 'raw', 'sid', 'str'),
}, },
extra=PREVENT_EXTRA extra=PREVENT_EXTRA
) )

View file

@ -17,6 +17,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import ast import ast
import os
import sys import sys
from io import BytesIO, TextIOWrapper from io import BytesIO, TextIOWrapper
@ -33,6 +34,44 @@ class AnsibleTextIOWrapper(TextIOWrapper):
super(AnsibleTextIOWrapper, self).write(to_text(s, self.encoding, errors='replace')) super(AnsibleTextIOWrapper, self).write(to_text(s, self.encoding, errors='replace'))
def find_executable(executable, cwd=None, path=None):
"""Finds the full path to the executable specified"""
# This is mostly a copy from test/runner/lib/util.py. Should be removed once validate-modules has been integrated
# into ansible-test
match = None
real_cwd = os.getcwd()
if not cwd:
cwd = real_cwd
if os.path.dirname(executable):
target = os.path.join(cwd, executable)
if os.path.exists(target) and os.access(target, os.F_OK | os.X_OK):
match = executable
else:
path = os.environ.get('PATH', os.path.defpath)
path_dirs = path.split(os.path.pathsep)
seen_dirs = set()
for path_dir in path_dirs:
if path_dir in seen_dirs:
continue
seen_dirs.add(path_dir)
if os.path.abspath(path_dir) == real_cwd:
path_dir = cwd
candidate = os.path.join(path_dir, executable)
if os.path.exists(candidate) and os.access(candidate, os.F_OK | os.X_OK):
match = candidate
break
return match
def find_globals(g, tree): def find_globals(g, tree):
"""Uses AST to find globals in an ast tree""" """Uses AST to find globals in an ast tree"""
for child in tree: for child in tree: