diff --git a/.travis.yml b/.travis.yml index 355808b..86efde1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ before_install: - source ${PULUMI_SCRIPTS}/ci/keep-failed-tests.sh install: # Install Pulumi 🍹 - - curl -fsSL https://get.pulumi.com/ | bash -s -- --version "1.13.0-alpha.1583701915" + - curl -fsSL https://get.pulumi.com/ | bash - export PATH="$HOME/.pulumi/bin:$PATH" # Install other tools. - source ${PULUMI_SCRIPTS}/ci/install-common-toolchain.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 3381b3e..76657eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,31 @@ } ``` +- Add support for writing policies in Python :tada: + (https://github.com/pulumi/pulumi-policy/pull/212). + + Example: + + ```python + def s3_no_public_read(args: ResourceValidationArgs, report_violation: ReportViolation): + if args.resource_type == "aws:s3/bucket:Bucket" and "acl" in args.props: + acl = args.props["acl"] + if acl == "public-read" or acl == "public-read-write": + report_violation("You cannot set public-read or public-read-write on an S3 bucket.") + + PolicyPack( + name="aws-policy-pack", + enforcement_level=EnforcementLevel.MANDATORY, + policies=[ + ResourceValidationPolicy( + name="s3-no-public-read", + description="Prohibits setting the publicRead or publicReadWrite permission on AWS S3 buckets.", + validate=s3_no_public_read, + ), + ], + ) + ``` + ## 0.4.0 (2020-01-30) ### Improvements diff --git a/Makefile b/Makefile index 8d1f287..53bb273 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ PROJECT_NAME := policy -SUB_PROJECTS := sdk/nodejs/policy +SUB_PROJECTS := sdk/nodejs/policy sdk/python include build/common.mk .PHONY: ensure diff --git a/scripts/publish_packages.sh b/scripts/publish_packages.sh index 6f39a7c..19e2f33 100755 --- a/scripts/publish_packages.sh +++ b/scripts/publish_packages.sh @@ -36,3 +36,9 @@ publish() { } publish policy + +echo "Publishing Pip package to pypi.org:" +twine upload \ + -u pulumi -p "${PYPI_PASSWORD}" \ + "${ROOT}/sdk/python/env/src/dist"/*.whl \ + --skip-existing \ diff --git a/sdk/nodejs/policy/package.json b/sdk/nodejs/policy/package.json index 109ea57..a4cb314 100644 --- a/sdk/nodejs/policy/package.json +++ b/sdk/nodejs/policy/package.json @@ -7,7 +7,7 @@ "homepage": "https://pulumi.io", "repository": "https://github.com/pulumi/pulumi-policy", "dependencies": { - "@pulumi/pulumi": "1.13.0-alpha.1583701915", + "@pulumi/pulumi": "^1.13.0", "google-protobuf": "^3.5.0", "grpc": "^1.20.2", "protobufjs": "^6.8.6" diff --git a/sdk/nodejs/policy/policy.ts b/sdk/nodejs/policy/policy.ts index 33e18a7..ae9e716 100644 --- a/sdk/nodejs/policy/policy.ts +++ b/sdk/nodejs/policy/policy.ts @@ -328,22 +328,22 @@ export interface PolicyCustomTimeouts { */ export interface PolicyProviderResource { /** - * The type of the resource provider. + * The type of the provider resource. */ type: string; /** - * The properties of the resource provider. + * The properties of the provider resource. */ props: Record; /** - * The URN of the resource provider. + * The URN of the provider resource. */ urn: string; /** - * The name of the resource provider. + * The name of the provider resource. */ name: string; } @@ -419,7 +419,7 @@ export interface StackValidationPolicy extends Policy { export type StackValidation = (args: StackValidationArgs, reportViolation: ReportViolation) => Promise | void; /** - * StackValidationArgs is the argument bag passed to a resource validation. + * StackValidationArgs is the argument bag passed to a stack validation. */ export interface StackValidationArgs { /** diff --git a/sdk/python/.gitignore b/sdk/python/.gitignore new file mode 100644 index 0000000..04019cc --- /dev/null +++ b/sdk/python/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.mypy_cache/ +*.pyc +/env/ +/*.egg-info +.venv/ diff --git a/sdk/python/.pylintrc b/sdk/python/.pylintrc new file mode 100644 index 0000000..7af4d56 --- /dev/null +++ b/sdk/python/.pylintrc @@ -0,0 +1,580 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns=.*_pb2.* + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=0 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + locally-enabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape, + + # Pulumi-specific exclusions begin here + too-few-public-methods, + too-many-public-methods, + too-many-instance-attributes, + wildcard-import, + global-statement, + invalid-name, + protected-access, + too-many-arguments, + too-many-branches, + too-many-locals, + too-many-return-statements, + too-many-statements, + missing-docstring, + fixme, + broad-except, + no-self-use, + unused-import, + unsubscriptable-object, + line-too-long + + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package.. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=200 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=yes + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement. +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception". +overgeneral-exceptions=Exception diff --git a/sdk/python/Makefile b/sdk/python/Makefile new file mode 100644 index 0000000..af9d984 --- /dev/null +++ b/sdk/python/Makefile @@ -0,0 +1,30 @@ +PROJECT_NAME := Pulumi Policy Python SDK +SEMVER := $(shell ../../scripts/get-version) +VERSION := $(shell ../../scripts/get-py-version HEAD) + +PYENV := ./env +PYENVSRC := $(PYENV)/src +PYENVSRCLIB := $(PYENVSRC)/pulumi_policy + +include ../../build/common.mk + +ensure:: + pipenv --python 3 install --dev + mkdir -p $(PYENVSRC) + +build:: + rm -rf $(PYENVSRC) && cp -R ./lib/. $(PYENVSRC)/ + sed -i.bak "s/\$${VERSION}/$(VERSION)/g" $(PYENVSRC)/setup.py && rm $(PYENVSRC)/setup.py.bak + sed -i.bak "s/\$${SEMVERSION}/$(SEMVER:v%=%)/g" $(PYENVSRCLIB)/version.py && rm $(PYENVSRCLIB)/version.py.bak + cp ../../README.md $(PYENVSRC) + cd $(PYENVSRC) && pipenv run python setup.py build bdist_wheel --universal + +lint:: + pipenv run mypy ./lib/pulumi_policy --config-file=mypy.ini + pipenv run pylint ./lib/pulumi_policy --rcfile=.pylintrc + +test_fast:: + pipenv run pip install ./env/src + pipenv run python -m unittest discover -s lib/test -v + +test_all:: test_fast diff --git a/sdk/python/Pipfile b/sdk/python/Pipfile new file mode 100644 index 0000000..77a3e40 --- /dev/null +++ b/sdk/python/Pipfile @@ -0,0 +1,13 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +pulumi = ">=1.0.0,<2.0.0" +protobuf = ">=3.6.0" +grpcio = ">=1.9.1" + +[dev-packages] +pylint = ">=2.1" +mypy = ">=0.77" diff --git a/sdk/python/Pipfile.lock b/sdk/python/Pipfile.lock new file mode 100644 index 0000000..ca0c60b --- /dev/null +++ b/sdk/python/Pipfile.lock @@ -0,0 +1,244 @@ +{ + "_meta": { + "hash": { + "sha256": "536d64ec4d61f78436a49d448e66952a2cf06b9981841745d12c63f8adac8e94" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "dill": { + "hashes": [ + "sha256:42d8ef819367516592a825746a18073ced42ca169ab1f5f4044134703e7a049c", + "sha256:9d14e06f0053081aef74120eee6affabb40a58c34f394ab73785fff8b70bbd88" + ], + "version": "==0.3.1.1" + }, + "grpcio": { + "hashes": [ + "sha256:02aef8ef1a5ac5f0836b543e462eb421df6048a7974211a906148053b8055ea6", + "sha256:07f82aefb4a56c7e1e52b78afb77d446847d27120a838a1a0489260182096045", + "sha256:1cff47297ee614e7ef66243dc34a776883ab6da9ca129ea114a802c5e58af5c1", + "sha256:1ec8fc865d8da6d0713e2092a27eee344cd54628b2c2065a0e77fff94df4ae00", + "sha256:1ef949b15a1f5f30651532a9b54edf3bd7c0b699a10931505fa2c80b2d395942", + "sha256:209927e65395feb449783943d62a3036982f871d7f4045fadb90b2d82b153ea8", + "sha256:25c77692ea8c0929d4ad400ea9c3dcbcc4936cee84e437e0ef80da58fa73d88a", + "sha256:28f27c64dd699b8b10f70da5f9320c1cffcaefca7dd76275b44571bd097f276c", + "sha256:355bd7d7ce5ff2917d217f0e8ddac568cb7403e1ce1639b35a924db7d13a39b6", + "sha256:4a0a33ada3f6f94f855f92460896ef08c798dcc5f17d9364d1735c5adc9d7e4a", + "sha256:4d3b6e66f32528bf43ca2297caca768280a8e068820b1c3dca0fcf9f03c7d6f1", + "sha256:5121fa96c79fc0ec81825091d0be5c16865f834f41b31da40b08ee60552f9961", + "sha256:57949756a3ce1f096fa2b00f812755f5ab2effeccedb19feeb7d0deafa3d1de7", + "sha256:586d931736912865c9790c60ca2db29e8dc4eace160d5a79fec3e58df79a9386", + "sha256:5ae532b93cf9ce5a2a549b74a2c35e3b690b171ece9358519b3039c7b84c887e", + "sha256:5dab393ab96b2ce4012823b2f2ed4ee907150424d2f02b97bd6f8dd8f17cc866", + "sha256:5ebc13451246de82f130e8ee7e723e8d7ae1827f14b7b0218867667b1b12c88d", + "sha256:68a149a0482d0bc697aac702ec6efb9d380e0afebf9484db5b7e634146528371", + "sha256:6db7ded10b82592c472eeeba34b9f12d7b0ab1e2dcad12f081b08ebdea78d7d6", + "sha256:6e545908bcc2ae28e5b190ce3170f92d0438cf26a82b269611390114de0106eb", + "sha256:6f328a3faaf81a2546a3022b3dfc137cc6d50d81082dbc0c94d1678943f05df3", + "sha256:706e2dea3de33b0d8884c4d35ecd5911b4ff04d0697c4138096666ce983671a6", + "sha256:80c3d1ce8820dd819d1c9d6b63b6f445148480a831173b572a9174a55e7abd47", + "sha256:8111b61eee12d7af5c58f82f2c97c2664677a05df9225ef5cbc2f25398c8c454", + "sha256:9713578f187fb1c4d00ac554fe1edcc6b3ddd62f5d4eb578b81261115802df8e", + "sha256:9c0669ba9aebad540fb05a33beb7e659ea6e5ca35833fc5229c20f057db760e8", + "sha256:9e9cfe55dc7ac2aa47e0fd3285ff829685f96803197042c9d2f0fb44e4b39b2c", + "sha256:a22daaf30037b8e59d6968c76fe0f7ff062c976c7a026e92fbefc4c4bf3fc5a4", + "sha256:a25b84e10018875a0f294a7649d07c43e8bc3e6a821714e39e5cd607a36386d7", + "sha256:a71138366d57901597bfcc52af7f076ab61c046f409c7b429011cd68de8f9fe6", + "sha256:b4efde5524579a9ce0459ca35a57a48ca878a4973514b8bb88cb80d7c9d34c85", + "sha256:b78af4d42985ab3143d9882d0006f48d12f1bc4ba88e78f23762777c3ee64571", + "sha256:bb2987eb3af9bcf46019be39b82c120c3d35639a95bc4ee2d08f36ecdf469345", + "sha256:c03ce53690fe492845e14f4ab7e67d5a429a06db99b226b5c7caa23081c1e2bb", + "sha256:c59b9280284b791377b3524c8e39ca7b74ae2881ba1a6c51b36f4f1bb94cee49", + "sha256:d18b4c8cacbb141979bb44355ee5813dd4d307e9d79b3a36d66eca7e0a203df8", + "sha256:d1e5563e3b7f844dbc48d709c9e4a75647e11d0387cc1fa0c861d3e9d34bc844", + "sha256:d22c897b65b1408509099f1c3334bd3704f5e4eb7c0486c57d0e212f71cb8f54", + "sha256:dbec0a3a154dbf2eb85b38abaddf24964fa1c059ee0a4ad55d6f39211b1a4bca", + "sha256:ed123037896a8db6709b8ad5acc0ed435453726ea0b63361d12de369624c2ab5", + "sha256:f3614dabd2cc8741850597b418bcf644d4f60e73615906c3acc407b78ff720b3", + "sha256:f9d632ce9fd485119c968ec6a7a343de698c5e014d17602ae2f110f1b05925ed", + "sha256:fb62996c61eeff56b59ab8abfcaa0859ec2223392c03d6085048b576b567459b" + ], + "index": "pypi", + "version": "==1.27.2" + }, + "protobuf": { + "hashes": [ + "sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab", + "sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f", + "sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a", + "sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0", + "sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4", + "sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2", + "sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee", + "sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07", + "sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151", + "sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a", + "sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f", + "sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7", + "sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956", + "sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306", + "sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961", + "sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481", + "sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a", + "sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80" + ], + "index": "pypi", + "version": "==3.11.3" + }, + "pulumi": { + "hashes": [ + "sha256:55b0f71851ea9afe95a06eace713dd6ea94436ce8675a93725ff88829dd2040f" + ], + "index": "pypi", + "version": "==1.12.1" + }, + "six": { + "hashes": [ + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + ], + "version": "==1.14.0" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a", + "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42" + ], + "version": "==2.3.3" + }, + "isort": { + "hashes": [ + "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", + "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" + ], + "version": "==4.3.21" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", + "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", + "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", + "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", + "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", + "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", + "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", + "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", + "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", + "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", + "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", + "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", + "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", + "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", + "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", + "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", + "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", + "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", + "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", + "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", + "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" + ], + "version": "==1.4.3" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "mypy": { + "hashes": [ + "sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2", + "sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1", + "sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164", + "sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761", + "sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce", + "sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27", + "sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754", + "sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae", + "sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9", + "sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600", + "sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65", + "sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8", + "sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913", + "sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3" + ], + "index": "pypi", + "version": "==0.770" + }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, + "pylint": { + "hashes": [ + "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd", + "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4" + ], + "index": "pypi", + "version": "==2.4.4" + }, + "six": { + "hashes": [ + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + ], + "version": "==1.14.0" + }, + "typed-ast": { + "hashes": [ + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + ], + "markers": "implementation_name == 'cpython' and python_version < '3.8'", + "version": "==1.4.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2", + "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d", + "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575" + ], + "version": "==3.7.4.1" + }, + "wrapt": { + "hashes": [ + "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" + ], + "version": "==1.11.2" + } + } +} diff --git a/sdk/python/lib/pulumi_policy/__init__.py b/sdk/python/lib/pulumi_policy/__init__.py new file mode 100644 index 0000000..81e9750 --- /dev/null +++ b/sdk/python/lib/pulumi_policy/__init__.py @@ -0,0 +1,35 @@ +# Copyright 2016-2018, Pulumi Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +The Pulumi Policy SDK for Python. +""" + +# Make all module members inside of this package available as package members. +from .policy import ( + EnforcementLevel, + Policy, + PolicyCustomTimeouts, + PolicyPack, + PolicyProviderResource, + PolicyResource, + PolicyResourceOptions, + ReportViolation, + ResourceValidation, + ResourceValidationArgs, + ResourceValidationPolicy, + StackValidation, + StackValidationArgs, + StackValidationPolicy, +) diff --git a/sdk/python/lib/pulumi_policy/policy.py b/sdk/python/lib/pulumi_policy/policy.py new file mode 100644 index 0000000..57c3f4f --- /dev/null +++ b/sdk/python/lib/pulumi_policy/policy.py @@ -0,0 +1,744 @@ +# Copyright 2016-2020, Pulumi Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from concurrent import futures +import re +import sys +import time + +from enum import Enum +from inspect import isawaitable +from typing import Any, Awaitable, Callable, Dict, List, NamedTuple, Optional, Union, cast +from abc import ABC + +import grpc +from google.protobuf import empty_pb2, json_format +from pulumi.runtime import proto +from pulumi.runtime.proto import analyzer_pb2_grpc + +from .version import SEMVERSION + +_ONE_DAY_IN_SECONDS = 60 * 60 * 24 + +_POLICY_PACK_NAME_RE = re.compile("^[a-zA-Z0-9-_.]{1,100}$") + +class PolicyPack: + """ + A policy pack contains one or more policies to enforce. + """ + + def __init__(self, + name: str, + policies: List['Policy'], + enforcement_level: Optional['EnforcementLevel'] = None) -> None: + """ + :param str name: The name of the policy pack. + :param List[Policy] policies: The policies associated with a policy pack. + :param Optional[EnforcementLevel] enforcement_level: Indicates what to do on policy + violation, e.g., block deployment but allow override with + proper permissions. This is the default used for all policies in the policy pack. + Individual policies can override. + """ + if not name: + raise TypeError("Missing name argument") + if not isinstance(name, str): + raise TypeError("Expected name to be a string") + if _POLICY_PACK_NAME_RE.match(name) is None: + raise TypeError(f"Invalid policy pack name {name}. Policy pack names may only contain " + + "alphanumerics, hyphens, underscores, or periods.") + if not policies: + raise TypeError("Missing policies argument") + if not isinstance(policies, list): + raise TypeError("Expected policies to be a list of policies") + for policy in policies: + if not isinstance(policy, Policy): + raise TypeError("Expected policies to be a list of policies") + if enforcement_level is not None and not isinstance(enforcement_level, EnforcementLevel): + raise TypeError( + "Expected enforcement_level to be an EnforcementLevel") + + # TODO[pulumi/pulumi-policy#208]: lookup the policy pack actual version. + version = "0.0.1" + + servicer = _PolicyAnalyzerServicer( + name, version, policies, enforcement_level if enforcement_level is not None else EnforcementLevel.ADVISORY) + server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) + analyzer_pb2_grpc.add_AnalyzerServicer_to_server( + servicer, server) + port = server.add_insecure_port(address="0.0.0.0:0") + server.start() + sys.stdout.buffer.write(f"{port}\n".encode()) + try: + while True: + time.sleep(_ONE_DAY_IN_SECONDS) + except KeyboardInterrupt: + server.stop(0) + + +class EnforcementLevel(Enum): + """ + Indicates the impact of a policy violation. + """ + + ADVISORY = "advisory" + MANDATORY = "mandatory" + DISABLED = "disabled" + + +class Policy(ABC): + """ + A policy function that returns true if a resource definition violates some policy (e.g., "no + public S3 buckets"), and a set of metadata useful for generating helpful messages when the policy + is violated. + """ + + name: str + """ + An ID for the policy. Must be unique within the current policy set. + """ + + description: str + """ + A brief description of the policy rule. e.g., "S3 buckets should have default encryption + enabled." + """ + + enforcement_level: Optional[EnforcementLevel] + """ + Indicates what to do on policy violation, e.g., block deployment but allow override with + proper permissions. + """ + + def __init__(self, + name: str, + description: str, + enforcement_level: Optional[EnforcementLevel] = None) -> None: + """ + :param str name: An ID for the policy. Must be unique within the current policy set. + :param str description: A brief description of the policy rule. e.g., "S3 buckets should have + default encryptionenabled." + :param Optional[EnforcementLevel] enforcement_level: Indicates what to do on policy violation, + e.g., block deployment but allow override with proper permissions. + """ + if not name: + raise TypeError("Missing name argument") + if not isinstance(name, str): + raise TypeError("Expected name to be a string") + if name == "all": + raise TypeError( + 'Invalid policy name "all"; "all" is a reserved name') + if not description: + raise TypeError("Missing description argument") + if not isinstance(description, str): + raise TypeError("Expected description to be a string") + if enforcement_level is not None and not isinstance(enforcement_level, EnforcementLevel): + raise TypeError( + "Expected enforcement_level to be an EnforcementLevel") + self.name = name + self.description = description + self.enforcement_level = enforcement_level + + +ReportViolation = Callable[[str, Optional[str]], None] +""" +ReportViolation is the callback signature used to report policy violations. +The first param is the violation message and the second is an optional +urn of the resource to associate with the violations. +""" + + +class ResourceValidationArgs: + """ + ResourceValidationArgs is the argument bag passed to a resource validation. + """ + + resource_type: str + """ + The type of the resource. + """ + + props: Dict[str, Any] + """ + The inputs of the resource. + """ + + urn: str + """ + The URN of the resource. + """ + + name: str + """ + The name of the resource. + """ + + opts: 'PolicyResourceOptions' + """ + The options of the resource. + """ + + provider: Optional['PolicyProviderResource'] + """ + The provider of the resource. + """ + + def __init__(self, + resource_type: str, + props: Dict[str, Any], + urn: str, + name: str, + opts: 'PolicyResourceOptions', + provider: Optional['PolicyProviderResource']) -> None: + self.resource_type = resource_type + self.props = props + self.urn = urn + self.name = name + self.opts = opts + self.provider = provider + + +class PolicyResourceOptions: + """ + PolicyResourceOptions is the bag of settings that control a resource's behavior. + """ + + protect: bool + """ + When set to true, protect ensures this resource cannot be deleted. + """ + + ignore_changes: List[str] + """ + Ignore changes to any of the specified properties. + """ + + delete_before_replace: Optional[bool] + """ + When set to true, indicates that this resource should be deleted before + its replacement is created when replacement is necessary. + """ + + aliases: List[str] + """ + Additional URNs that should be aliased to this resource. + """ + + custom_timeouts: 'PolicyCustomTimeouts' + """ + Custom timeouts for resource create, update, and delete operations. + """ + + additional_secret_outputs: List[str] + """ + Outputs that should always be treated as secrets. + """ + + def __init__(self, + protect: bool, + ignore_changes: List[str], + delete_before_replace: Optional[bool], + aliases: List[str], + custom_timeouts: 'PolicyCustomTimeouts', + additional_secret_outputs: List[str]) -> None: + self.protect = protect + self.ignore_changes = ignore_changes + self.delete_before_replace = delete_before_replace + self.aliases = aliases + self.custom_timeouts = custom_timeouts + self.additional_secret_outputs = additional_secret_outputs + + +class PolicyCustomTimeouts: + """ + Custom timeout options. + """ + + create_seconds: float + """ + The create resource timeout. + """ + + update_seconds: float + """ + The update resource timeout. + """ + + delete_seconds: float + """ + The delete resource timeout. + """ + + def __init__(self, + create_seconds: float, + update_seconds: float, + delete_seconds: float) -> None: + self.create_seconds = create_seconds + self.update_seconds = update_seconds + self.delete_seconds = delete_seconds + + +class PolicyProviderResource: + """ + Information about the provider. + """ + + resource_type: str + """ + The type of the provider resource. + """ + + props: Dict[str, Any] + """ + The properties of the provider resource. + """ + + urn: str + """ + The URN of the provider resource. + """ + + name: str + """ + The name of the provider resource. + """ + + def __init__(self, + resource_type: str, + props: Dict[str, Any], + urn: str, + name: str) -> None: + self.resource_type = resource_type + self.props = props + self.urn = urn + self.name = name + + +ResourceValidation = Callable[[ResourceValidationArgs, ReportViolation], Optional[Awaitable]] +""" +ResourceValidation is the callback signature for a `ResourceValidationPolicy`. A resource validation +is passed `args` with more information about the resource and a `ReportViolation` callback that can be +used to report a policy violation. `ReportViolation` can be called multiple times to report multiple +violations against the same resource. `ReportViolation` must be passed a message about the violation. +The `ReportViolation` signature accepts an optional `urn` argument, which is ignored when validating +resources (the `urn` of the resource being validated is always used). +""" + + +class ResourceValidationPolicy(Policy): + """ + ResourceValidationPolicy is a policy that validates a resource definition. + """ + + __validate: Optional[Union[ResourceValidation, List[ResourceValidation]]] + """ + Private field holding the optional validation callback. + """ + + def validate(self, args: ResourceValidationArgs, report_violation: ReportViolation) -> Optional[Awaitable]: + if not self.__validate: + raise NotImplementedError(f'`validate must be overridden by policy "{self.name}"' + + ' since `validate was not specified') + + awaitable_results: List[Awaitable] = [] + + validations = (self.__validate if isinstance(self.__validate, list) + else [self.__validate]) + + for validation in validations: + result = validation(args, report_violation) + if result is not None and isawaitable(result): + awaitable_results.append(cast(Awaitable, result)) + + if awaitable_results: + return asyncio.wait(awaitable_results) + + return None + + def __init__(self, + name: str, + description: str, + validate: Optional[Union[ResourceValidation, List[ResourceValidation]]] = None, + enforcement_level: Optional[EnforcementLevel] = None) -> None: + """ + :param str name: An ID for the policy. Must be unique within the current policy set. + :param str description: A brief description of the policy rule. e.g., "S3 buckets should have + default encryptionenabled." + :param Optional[Union[ResourceValidation, List[ResourceValidation]]] validate: A callback function + that validates if a resource definition violates a policy (e.g. "S3 buckets can't be public"). + A single callback function can be specified, or multiple functions, which are called in order. + :param Optional[EnforcementLevel] enforcement_level: Indicates what to do on policy violation, + e.g., block deployment but allow override with proper permissions. + """ + super().__init__(name, description, enforcement_level) + + # If this instance isn't a subclass, then validate must be specified. + not_subclassed = type(self) is ResourceValidationPolicy # pylint: disable=unidiomatic-typecheck + if not_subclassed and not validate: + raise TypeError("Missing validate argument") + + if validate: + if not callable(validate) and not isinstance(validate, list): + raise TypeError("Expected validate to be callable or a list of callables") + if isinstance(validate, list) and any(not callable(v) for v in validate): + raise TypeError("Expected validate to be callable or a list of callables") + + self.__validate = validate # type: ignore + + +class PolicyResource: + """ + PolicyResource represents a resource in the stack. + """ + + resource_type: str + """ + The type of the resource. + """ + + props: Dict[str, Any] + """ + The outputs of the resource. + """ + + urn: str + """ + The URN of the resource. + """ + + name: str + """ + The name of the resource. + """ + + opts: PolicyResourceOptions + """ + The options of the resource. + """ + + provider: Optional[PolicyProviderResource] + """ + The provider of the resource. + """ + + parent: Optional['PolicyResource'] + """ + An optional parent that this resource belongs to. + """ + + dependencies: List['PolicyResource'] + """ + The dependencies of the resource. + """ + + property_dependencies: Dict[str, List['PolicyResource']] + """ + The set of dependencies that affect each property. + """ + + def __init__(self, + resource_type: str, + props: Dict[str, Any], + urn: str, + name: str, + opts: PolicyResourceOptions, + provider: Optional[PolicyProviderResource], + parent: Optional['PolicyResource'], + dependencies: List['PolicyResource'], + property_dependencies: Dict[str, List['PolicyResource']]) -> None: + self.resource_type = resource_type + self.props = props + self.urn = urn + self.name = name + self.opts = opts + self.provider = provider + self.parent = parent + self.dependencies = dependencies + self.property_dependencies = property_dependencies + + +class StackValidationArgs: + """ + StackValidationArgs is the argument bag passed to a stack validation. + """ + + resources: List[PolicyResource] + """ + The resources in the stack. + """ + + def __init__(self, resources: List[PolicyResource]) -> None: + self.resources = resources + + +StackValidation = Callable[[StackValidationArgs, ReportViolation], Optional[Awaitable]] +""" +StackValidation is the callback signature for a `StackValidationPolicy`. A stack validation is passed +`args` with more information about the stack and a `report_violation` callback that can be used to +report a policy violation. `report_violation` can be called multiple times to report multiple violations +against the stack. `report_violation` must be passed a message about the violation, and an optional `urn` +to a resource in the stack that's in violation of the policy. Not specifying a `urn` indicates the +overall stack is in violation of the policy. +""" + + +class StackValidationPolicy(Policy): + """ + StackValidationPolicy is a policy that validates a stack. + """ + + __validate: Optional[StackValidation] + """ + Private field holding the optional validation callback. + """ + + def validate(self, args: StackValidationArgs, report_violation: ReportViolation) -> Optional[Awaitable]: + if not self.__validate: + raise NotImplementedError(f'`validate` must be overridden by policy "{self.name}"' + + ' since `validate` was not specified') + + result = self.__validate(args, report_violation) + if result is not None and isawaitable(result): + return cast(Awaitable, result) + + return None + + def __init__(self, + name: str, + description: str, + validate: Optional[StackValidation] = None, + enforcement_level: Optional[EnforcementLevel] = None) -> None: + """ + :param str name: An ID for the policy. Must be unique within the current policy set. + :param str description: A brief description of the policy rule. e.g., "S3 buckets should have + default encryptionenabled." + :param Optional[StackValidation] validate: A callback function that validates if a stack violates a policy. + :param Optional[EnforcementLevel] enforcement_level: Indicates what to do on policy violation, + e.g., block deployment but allow override with proper permissions. + """ + super().__init__(name, description, enforcement_level) + + # If this instance isn't a subclass, then validate must be specified. + not_subclassed = type(self) is StackValidationPolicy # pylint: disable=unidiomatic-typecheck + if not_subclassed and not validate: + raise TypeError("Missing validate argument") + + if validate: + if not callable(validate): + raise TypeError("Expected validate to be callable") + + self.__validate = validate # type: ignore + + +class _PolicyAnalyzerServicer(proto.AnalyzerServicer): + __policy_pack_name: str + __policy_pack_version: str + __policies: List[Policy] + __policy_pack_enforcement_level: EnforcementLevel + + def Analyze(self, request, context): + diagnostics: List[proto.AnalyzeDiagnostic] = [] + for policy in self.__policies: + enforcement_level = self._get_enforcement_level(policy) + if enforcement_level == EnforcementLevel.DISABLED or not isinstance(policy, ResourceValidationPolicy): + continue + + report_violation = self._create_report_violation(diagnostics, policy.name, + policy.description, enforcement_level) + + # TODO[pulumi/pulumi-policy#208]: Deserialize properties + # TODO[pulumi/pulumi-policy#208]: Unknown checking proxy + props = json_format.MessageToDict(request.properties) + opts = self._get_resource_options(request) + provider = self._get_provider_resource(request) + args = ResourceValidationArgs(request.type, props, request.urn, request.name, opts, provider) + + result = policy.validate(args, report_violation) + if isawaitable(result): + loop = asyncio.new_event_loop() + loop.run_until_complete(result) + loop.close() + + return proto.AnalyzeResponse(diagnostics=diagnostics) + + def AnalyzeStack(self, request, context): + diagnostics: List[proto.AnalyzeDiagnostic] = [] + for policy in self.__policies: + enforcement_level = self._get_enforcement_level(policy) + if enforcement_level == EnforcementLevel.DISABLED or not isinstance(policy, StackValidationPolicy): + continue + + report_violation = self._create_report_violation(diagnostics, policy.name, + policy.description, enforcement_level) + + class IntermediateStackResource(NamedTuple): + resource: PolicyResource + parent: Optional[str] + dependencies: List[str] + property_dependencies: Dict[str, List[str]] + + intermediates: List[IntermediateStackResource] = [] + for r in request.resources: + # TODO[pulumi/pulumi-policy#208]: Deserialize properties + # TODO[pulumi/pulumi-policy#208]: Unknown checking proxy + props = json_format.MessageToDict(r.properties) + opts = self._get_resource_options(r) + provider = self._get_provider_resource(r) + resource = PolicyResource(r.type, props, r.urn, r.name, opts, provider, None, [], {}) + property_dependencies: Dict[str, List[str]] = {} + for k, v in r.propertyDependencies.items(): + property_dependencies[k] = list(v.urns) + intermediates.append(IntermediateStackResource(resource, r.parent, list(r.dependencies), property_dependencies)) + + # Create a map of URNs to resources, used to fill in the parent and dependencies + # with references to the actual resource objects. + urns_to_resources: Dict[str, PolicyResource] = {} + for i in intermediates: + urns_to_resources[i.resource.urn] = i.resource + + # Go through each intermediate result and set the parent and dependencies. + for i in intermediates: + # If the resource has a parent, lookup and set it to the actual resource object. + if i.parent is not None and i.parent in urns_to_resources: + i.resource.parent = urns_to_resources[i.parent] + + # Set dependencies to actual resource objects. + for d in i.dependencies: + if d in urns_to_resources: + i.resource.dependencies.append(urns_to_resources[d]) + + # Set property_dependencies to actual resource objects. + for k in i.property_dependencies: + v = i.property_dependencies[k] + deps: List[PolicyResource] = [] + for d in v: + if d in urns_to_resources: + deps.append(urns_to_resources[d]) + i.resource.property_dependencies[k] = deps + + resources: List[PolicyResource] = [] + for i in intermediates: + resources.append(i.resource) + args = StackValidationArgs(resources) + + result = policy.validate(args, report_violation) + if isawaitable(result): + loop = asyncio.new_event_loop() + loop.run_until_complete(result) + loop.close() + + return proto.AnalyzeResponse(diagnostics=diagnostics) + + def GetAnalyzerInfo(self, request, context): + policies: List[proto.PolicyInfo] = [] + for policy in self.__policies: + enforcement_level = (policy.enforcement_level if policy.enforcement_level is not None + else self.__policy_pack_enforcement_level) + policies.append(proto.PolicyInfo( + name=policy.name, + description=policy.description, + enforcementLevel=self._map_enforcement_level(enforcement_level), + # TODO[pulumi/pulumi-policy#210]: Expose config schema + )) + + return proto.AnalyzerInfo( + name=self.__policy_pack_name, + version=self.__policy_pack_version, + supportsConfig=False, # TODO[pulumi/pulumi-policy#210]: Set to True when config support is added + policies=policies, + ) + + def GetPluginInfo(self, request, context): + return proto.PluginInfo(version=SEMVERSION) + + def Configure(self, request, context): + # TODO[pulumi/pulumi-policy#210]: Add support for config + return empty_pb2.Empty() + + def __init__(self, + name: str, + version: str, + policies: List[Policy], + enforcement_level: EnforcementLevel) -> None: + assert name and isinstance(name, str) + assert version and isinstance(version, str) + assert policies and isinstance(policies, list) + assert enforcement_level and isinstance( + enforcement_level, EnforcementLevel) + self.__policy_pack_name = name + self.__policy_pack_version = version + self.__policies = policies + self.__policy_pack_enforcement_level = enforcement_level + + def _get_enforcement_level(self, policy: Policy) -> EnforcementLevel: + return (policy.enforcement_level if policy.enforcement_level is not None + else self.__policy_pack_enforcement_level) + + def _create_report_violation(self, + diagnostics: List[Any], + policy_name: str, + policy_description: str, + enforcement_level: EnforcementLevel) -> ReportViolation: + def report_violation(message: str, urn: Optional[str] = None) -> None: + if message and not isinstance(message, str): + raise TypeError("Expected message to be a string") + if urn is not None and not isinstance(urn, str): + raise TypeError("Expected urn to be a string") + + violation_message = policy_description + if message: + violation_message += f"\n{message}" + + diagnostics.append(proto.AnalyzeDiagnostic( + policyName=policy_name, + policyPackName=self.__policy_pack_name, + policyPackVersion=self.__policy_pack_version, + message=violation_message, + urn=urn if urn else "", + description=policy_description, + enforcementLevel=self._map_enforcement_level(enforcement_level), + )) + return report_violation + + def _map_enforcement_level(self, enforcement_level: EnforcementLevel) -> int: + if enforcement_level == EnforcementLevel.ADVISORY: + return proto.ADVISORY + if enforcement_level == EnforcementLevel.MANDATORY: + return proto.MANDATORY + if enforcement_level == EnforcementLevel.DISABLED: + return proto.DISABLED + raise AssertionError( + f"unknown enforcement level: {enforcement_level}") + + def _get_resource_options(self, request) -> PolicyResourceOptions: + opts = request.options + protect = opts.protect + ignore_changes = opts.ignoreChanges + delete_before_replace = None if not opts.deleteBeforeReplaceDefined else opts.deleteBeforeReplace + aliases = opts.aliases + custom_timeouts = (PolicyCustomTimeouts(opts.customTimeouts.create, opts.customTimeouts.update, + opts.customTimeouts.delete) if opts.HasField("customTimeouts") + else PolicyCustomTimeouts(0, 0, 0)) + additional_secret_outputs = opts.additionalSecretOutputs + return PolicyResourceOptions( + protect, ignore_changes, delete_before_replace, aliases, custom_timeouts, additional_secret_outputs) + + def _get_provider_resource(self, request) -> Optional[PolicyProviderResource]: + if not request.HasField("provider"): + return None + prov = request.provider + # TODO[pulumi/pulumi-policy#208]: deserialize properties + # TODO[pulumi/pulumi-policy#208]: unknown checking proxy + props = json_format.MessageToDict(prov.properties) + return PolicyProviderResource(prov.type, props, prov.urn, prov.name) diff --git a/sdk/python/lib/pulumi_policy/py.typed b/sdk/python/lib/pulumi_policy/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/sdk/python/lib/pulumi_policy/version.py b/sdk/python/lib/pulumi_policy/version.py new file mode 100644 index 0000000..2dd96f1 --- /dev/null +++ b/sdk/python/lib/pulumi_policy/version.py @@ -0,0 +1,15 @@ +# Copyright 2016-2020, Pulumi Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +SEMVERSION = "${SEMVERSION}" diff --git a/sdk/python/lib/setup.py b/sdk/python/lib/setup.py new file mode 100644 index 0000000..2e6f245 --- /dev/null +++ b/sdk/python/lib/setup.py @@ -0,0 +1,41 @@ +# Copyright 2016-2020, Pulumi Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The Pulumi Policy Python SDK.""" + +from setuptools import setup, find_packages + +def readme(): + with open('README.md', encoding='utf-8') as f: + return f.read() + +setup(name='pulumi_policy', + version='${VERSION}', + description='Pulumi\'s Policy Python SDK', + long_description=readme(), + long_description_content_type='text/markdown', + url='https://github.com/pulumi/pulumi-policy', + license='Apache 2.0', + packages=find_packages(exclude=("test*",)), + package_data={ + 'pulumi_policy': [ + 'py.typed' + ] + }, + install_requires=[ + 'pulumi>=1.13.0,<2.0.0', + 'protobuf>=3.6.0', + 'grpcio>=1.9.1' + ], + zip_safe=False) diff --git a/sdk/python/lib/test/__init__.py b/sdk/python/lib/test/__init__.py new file mode 100644 index 0000000..032fa65 --- /dev/null +++ b/sdk/python/lib/test/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2016-2020, Pulumi Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +The Pulumi Policy SDK test package. +""" diff --git a/sdk/python/lib/test/test_policy.py b/sdk/python/lib/test/test_policy.py new file mode 100644 index 0000000..c9fc522 --- /dev/null +++ b/sdk/python/lib/test/test_policy.py @@ -0,0 +1,254 @@ +# Copyright 2016-2020, Pulumi Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from inspect import isawaitable +from typing import List, Optional, Union +import unittest + +from pulumi_policy import ( + EnforcementLevel, + PolicyPack, + ReportViolation, + ResourceValidationPolicy, + StackValidationPolicy, +) + +NOP = lambda: None +NOP_POLICY = ResourceValidationPolicy("nop", "nop", NOP) + +def run_policy(policy: Union[ResourceValidationPolicy, StackValidationPolicy]) -> List[str]: + violations = [] + def report(message: str, urn: Optional[str] = None): + violations.append(message) + + result = policy.validate(None, report) + if isawaitable(result): + loop = asyncio.new_event_loop() + loop.run_until_complete(result) + loop.close() + + return violations + +class PolicyPackTests(unittest.TestCase): + def test_int_raises(self): + self.assertRaises(TypeError, lambda: PolicyPack(None, [NOP_POLICY])) + self.assertRaises(TypeError, lambda: PolicyPack("", [NOP_POLICY])) + self.assertRaises(TypeError, lambda: PolicyPack(1, [NOP_POLICY])) + self.assertRaises(TypeError, lambda: PolicyPack(("a" * 100) + "a", [NOP_POLICY])) + self.assertRaises(TypeError, lambda: PolicyPack("*", [NOP_POLICY])) + + self.assertRaises(TypeError, lambda: PolicyPack("policies", None)) + self.assertRaises(TypeError, lambda: PolicyPack("policies", "")) + self.assertRaises(TypeError, lambda: PolicyPack("policies", 1)) + self.assertRaises(TypeError, lambda: PolicyPack("policies", [])) + self.assertRaises(TypeError, lambda: PolicyPack("policies", [None])) + self.assertRaises(TypeError, lambda: PolicyPack("policies", [""])) + self.assertRaises(TypeError, lambda: PolicyPack("policies", [1])) + + self.assertRaises(TypeError, lambda: PolicyPack("policies", [NOP_POLICY], "")) + self.assertRaises(TypeError, lambda: PolicyPack("policies", [NOP_POLICY], 1)) + +class ResourceValidationPolicyTests(unittest.TestCase): + def test_init_raises(self): + self.assertRaises(TypeError, lambda: ResourceValidationPolicy(None, "desc", NOP)) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("", "desc", NOP)) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy(1, "desc", NOP)) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("all", "desc", NOP)) + + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", None, NOP)) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "", NOP)) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", 1, NOP)) + + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc")) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc", None)) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc", "")) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc", 1)) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc", [])) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc", [None])) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc", [""])) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc", [1])) + + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc", NOP, "")) + self.assertRaises(TypeError, lambda: ResourceValidationPolicy("name", "desc", NOP, 1)) + + def test_init(self): + ResourceValidationPolicy("name", "desc", NOP) + ResourceValidationPolicy("name", "desc", [NOP]) + ResourceValidationPolicy("name", "desc", NOP, EnforcementLevel.ADVISORY) + ResourceValidationPolicy("name", "desc", NOP, EnforcementLevel.MANDATORY) + ResourceValidationPolicy("name", "desc", NOP, EnforcementLevel.DISABLED) + + def test_async_validate(self): + async def validate(args, report_violation: ReportViolation): + report_violation("first") + await asyncio.sleep(0.1) + report_violation("second") + + policy = ResourceValidationPolicy("name", "desc", validate) + violations = run_policy(policy) + self.assertEqual(["first", "second"], violations) + + def test_multiple_async_validate(self): + async def validate_one(args, report_violation: ReportViolation): + report_violation("first") + await asyncio.sleep(0.1) + report_violation("second") + + async def validate_two(args, report_violation: ReportViolation): + report_violation("third") + await asyncio.sleep(0.1) + report_violation("fourth") + + policy = ResourceValidationPolicy("name", "desc", [validate_one, validate_two]) + violations = run_policy(policy) + self.assertCountEqual(["first", "second", "third", "fourth"], violations) + + def test_multiple_async_nonasync_mix_validate(self): + async def validate_one(args, report_violation: ReportViolation): + report_violation("first") + await asyncio.sleep(0.1) + report_violation("second") + + async def validate_two(args, report_violation: ReportViolation): + report_violation("third") + await asyncio.sleep(0.1) + report_violation("fourth") + + def validate_three(args, report_violation: ReportViolation): + report_violation("fifth") + + policy = ResourceValidationPolicy("name", "desc", [validate_one, validate_two, validate_three]) + violations = run_policy(policy) + self.assertCountEqual(["first", "second", "third", "fourth", "fifth"], violations) + + +class ResourceValidationPolicySubclassNoValidateOverrideTests(unittest.TestCase): + class Subclass(ResourceValidationPolicy): + def __init__(self): + super().__init__("name", "desc") + + def test_validate_raises(self): + policy = self.Subclass() + self.assertRaises(NotImplementedError, lambda: policy.validate(None, None)) + + +class ResourceValidationPolicySubclassValidateOverrideTests(unittest.TestCase): + class Subclass(ResourceValidationPolicy): + def validate(self, args, report_violation): + report_violation("first") + report_violation("second") + + def __init__(self): + super().__init__("name", "desc") + + def test_validate(self): + policy = self.Subclass() + violations = run_policy(policy) + self.assertEqual(["first", "second"], violations) + + +class ResourceValidationPolicySubclassAsyncValidateTests(unittest.TestCase): + class Subclass(ResourceValidationPolicy): + async def validate(self, args, report_violation): + report_violation("first") + await asyncio.sleep(0.1) + report_violation("second") + + def __init__(self): + super().__init__("name", "desc") + + def test_validate(self): + policy = self.Subclass() + violations = run_policy(policy) + self.assertEqual(["first", "second"], violations) + + +class StackValidationPolicyTests(unittest.TestCase): + def test_init_raises(self): + self.assertRaises(TypeError, lambda: StackValidationPolicy(None, "desc", NOP)) + self.assertRaises(TypeError, lambda: StackValidationPolicy("", "desc", NOP)) + self.assertRaises(TypeError, lambda: StackValidationPolicy(1, "desc", NOP)) + self.assertRaises(TypeError, lambda: StackValidationPolicy("all", "desc", NOP)) + + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", None, NOP)) + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", "", NOP)) + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", 1, NOP)) + + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", "desc")) + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", "desc", None)) + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", "desc", "")) + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", "desc", 1)) + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", "desc", [])) + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", "desc", [NOP])) + + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", "desc", NOP, "")) + self.assertRaises(TypeError, lambda: StackValidationPolicy("name", "desc", NOP, 1)) + + def test_init(self): + StackValidationPolicy("name", "desc", NOP) + StackValidationPolicy("name", "desc", NOP, EnforcementLevel.ADVISORY) + StackValidationPolicy("name", "desc", NOP, EnforcementLevel.MANDATORY) + StackValidationPolicy("name", "desc", NOP, EnforcementLevel.DISABLED) + + def test_async_validate(self): + async def validate(args, report_violation: ReportViolation): + report_violation("first") + await asyncio.sleep(0.1) + report_violation("second") + + policy = StackValidationPolicy("name", "desc", validate) + violations = run_policy(policy) + self.assertEqual(["first", "second"], violations) + + +class StackValidationPolicySubclassNoValidateOverrideTests(unittest.TestCase): + class Subclass(StackValidationPolicy): + def __init__(self): + super().__init__("name", "desc") + + def test_validate_raises(self): + policy = self.Subclass() + self.assertRaises(NotImplementedError, lambda: policy.validate(None, None)) + + +class StackValidationPolicySubclassValidateOverrideTests(unittest.TestCase): + class Subclass(StackValidationPolicy): + def validate(self, args, report_violation): + report_violation("first") + report_violation("second") + + def __init__(self): + super().__init__("name", "desc") + + def test_validate(self): + policy = self.Subclass() + violations = run_policy(policy) + self.assertEqual(["first", "second"], violations) + + +class StackValidationPolicySubclassAsyncValidateTests(unittest.TestCase): + class Subclass(StackValidationPolicy): + async def validate(self, args, report_violation): + report_violation("first") + await asyncio.sleep(0.1) + report_violation("second") + + def __init__(self): + super().__init__("name", "desc") + + def test_validate(self): + policy = self.Subclass() + violations = run_policy(policy) + self.assertEqual(["first", "second"], violations) diff --git a/sdk/python/mypy.ini b/sdk/python/mypy.ini new file mode 100644 index 0000000..a1fde9d --- /dev/null +++ b/sdk/python/mypy.ini @@ -0,0 +1,8 @@ +# Global options: + +[mypy] + +# Per-module options: + +[mypy-grpc] +ignore_missing_imports = True diff --git a/tests/integration/enforcementlevel/policy-pack-python/PulumiPolicy.yaml b/tests/integration/enforcementlevel/policy-pack-python/PulumiPolicy.yaml new file mode 100644 index 0000000..d199d5f --- /dev/null +++ b/tests/integration/enforcementlevel/policy-pack-python/PulumiPolicy.yaml @@ -0,0 +1 @@ +runtime: python diff --git a/tests/integration/enforcementlevel/policy-pack-python/__main__.py b/tests/integration/enforcementlevel/policy-pack-python/__main__.py new file mode 100644 index 0000000..6c29538 --- /dev/null +++ b/tests/integration/enforcementlevel/policy-pack-python/__main__.py @@ -0,0 +1,82 @@ +# Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +from typing import List, NamedTuple, Optional + +from pulumi import Config + +from pulumi_policy import ( + EnforcementLevel, + PolicyPack, + ResourceValidationPolicy, + StackValidationPolicy, +) + + +class Scenario(NamedTuple): + pack: Optional[EnforcementLevel] + policy: Optional[EnforcementLevel] + + +# Build a set of scenarios to test +enforcement_levels = [EnforcementLevel.ADVISORY, EnforcementLevel.DISABLED, EnforcementLevel.MANDATORY, None] +scenarios: List[Scenario] = [{}] +for pack in enforcement_levels: + for policy in enforcement_levels: + scenarios.append(Scenario(pack, policy)) + + +# Get the current scenario +config = Config() +test_scenario = config.require_int("scenario") +if test_scenario >= len(scenarios): + raise AssertionError(f"Unexpected test_scenario {test_scenario}.") +scenario = scenarios[test_scenario] + + +# Generate a Policy Pack name for the scenario. +pack: str = scenario.pack.value if scenario.pack is not None else "none" +policy: str = f"-{scenario.policy.value}" if scenario.policy is not None else "" +policy_pack_name = f"enforcementlevel-{pack}{policy}-test-policy" + + +# Whether the validate function should raise an exception (to validate that it doesn't run). +validate_function_raises = ( + (scenario.pack == EnforcementLevel.DISABLED and + (scenario.policy == EnforcementLevel.DISABLED or scenario.policy is None)) or + scenario.policy == EnforcementLevel.DISABLED) + + +# Create a Policy Pack instance for the scenario. +def validate_resource(args, report_violation): + if validate_function_raises: + raise AssertionError("validate-resource should never be called.") + report_violation("validate-resource-violation-message") + + +def validate_stack(args, report_violation): + if validate_function_raises: + raise AssertionError("validate-stack should never be called.") + report_violation("validate-stack-violation-message") + + + + + +PolicyPack( + name=policy_pack_name, + enforcement_level=scenario.pack, + policies=[ + ResourceValidationPolicy( + name="validate-resource", + description="Always reports a resource violation.", + enforcement_level=scenario.policy, + validate=validate_resource, + ), + StackValidationPolicy( + name="validate-stack", + description="Always reports a stack violation.", + enforcement_level=scenario.policy, + validate=validate_stack, + ), + ], +) diff --git a/tests/integration/enforcementlevel/policy-pack-python/requirements.txt b/tests/integration/enforcementlevel/policy-pack-python/requirements.txt new file mode 100644 index 0000000..0081530 --- /dev/null +++ b/tests/integration/enforcementlevel/policy-pack-python/requirements.txt @@ -0,0 +1 @@ +pulumi>=1.0.0 diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index f1cec13..7b7d33c 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -106,6 +106,7 @@ func runPolicyPackIntegrationTest( abortIfFailed(t) // Get dependencies. + var venvCreated bool switch runtime { case NodeJS: e.RunCommand("yarn", "install") @@ -116,10 +117,37 @@ func runPolicyPackIntegrationTest( abortIfFailed(t) e.RunCommand("pipenv", "run", "pip", "install", "-r", "requirements.txt") abortIfFailed(t) + venvCreated = true default: t.Fatalf("Unexpected runtime value.") } + // If we have a Python policy pack, create the virtual environment (if one doesn't already exist), + // and install dependencies into it. If the test uses a Python program, the virtual environment and + // activation will be shared between the program and policy pack. + var hasPythonPack bool + pythonPackDir := filepath.Join(e.RootPath, "policy-pack-python") + if _, err := os.Stat(pythonPackDir); !os.IsNotExist(err) { + hasPythonPack = true + + if !venvCreated { + e.RunCommand("pipenv", "--python", "3") + abortIfFailed(t) + } + + pythonPackRequirements := filepath.Join(pythonPackDir, "requirements.txt") + if _, err := os.Stat(pythonPackRequirements); !os.IsNotExist(err) { + e.RunCommand("pipenv", "run", "pip", "install", "-r", pythonPackRequirements) + abortIfFailed(t) + } + + dep := filepath.Join("..", "..", "sdk", "python", "env", "src") + dep, err = filepath.Abs(dep) + assert.NoError(t, err) + e.RunCommand("pipenv", "run", "pip", "install", "-e", dep) + abortIfFailed(t) + } + // Initial configuration. for k, v := range initialConfig { e.RunCommand("pulumi", "config", "set", k, v) @@ -134,76 +162,86 @@ func runPolicyPackIntegrationTest( }() assert.True(t, len(scenarios) > 0, "no test scenarios provided") - for idx, scenario := range scenarios { - // Create a sub-test so go test will output data incrementally, which will let - // a CI system like Travis know not to kill the job if no output is sent after 10m. - // idx+1 to make it 1-indexed. - scenarioName := fmt.Sprintf("scenario_%d", idx+1) - t.Run(scenarioName, func(t *testing.T) { + runScenarios := func(policyPackDirectoryPath string) { + t.Run(policyPackDirectoryPath, func(t *testing.T) { e.T = t - e.RunCommand("pulumi", "config", "set", "scenario", fmt.Sprintf("%d", idx+1)) - - cmd := "pulumi" - args := []string{"up", "--yes", "--policy-pack", packDir} - - // If there is config for the scenario, write it out to a file and pass the file path - // as a --policy-pack-config argument. - if len(scenario.PolicyPackConfig) > 0 { - // Marshal the config to JSON, with indentation for easier debugging. - bytes, err := json.MarshalIndent(scenario.PolicyPackConfig, "", " ") - if err != nil { - t.Fatalf("error marshalling policy config to JSON: %v", err) - } - - // Change to the config directory. - configDir := filepath.Join(e.RootPath, "config", scenarioName) - e.CWD = configDir - - // Write the JSON to a file. - filename := "policy-config.json" - e.WriteTestFile(filename, string(bytes)) - abortIfFailed(t) - - // Add the policy config argument. - policyConfigFile := filepath.Join(configDir, filename) - args = append(args, "--policy-pack-config", policyConfigFile) - - // Change back to the program directory to proceed with the update. - e.CWD = programDir - } - - if runtime == Python { - cmd = "pipenv" - args = append([]string{"run", "pulumi"}, args...) - } + for idx, scenario := range scenarios { + // Create a sub-test so go test will output data incrementally, which will let + // a CI system like Travis know not to kill the job if no output is sent after 10m. + // idx+1 to make it 1-indexed. + scenarioName := fmt.Sprintf("scenario_%d", idx+1) + t.Run(scenarioName, func(t *testing.T) { + e.T = t + + e.RunCommand("pulumi", "config", "set", "scenario", fmt.Sprintf("%d", idx+1)) + + cmd := "pulumi" + args := []string{"up", "--yes", "--policy-pack", policyPackDirectoryPath} + + // If there is config for the scenario, write it out to a file and pass the file path + // as a --policy-pack-config argument. + if len(scenario.PolicyPackConfig) > 0 { + // Marshal the config to JSON, with indentation for easier debugging. + bytes, err := json.MarshalIndent(scenario.PolicyPackConfig, "", " ") + if err != nil { + t.Fatalf("error marshalling policy config to JSON: %v", err) + } + + // Change to the config directory. + configDir := filepath.Join(e.RootPath, "config", scenarioName) + e.CWD = configDir + + // Write the JSON to a file. + filename := "policy-config.json" + e.WriteTestFile(filename, string(bytes)) + abortIfFailed(t) + + // Add the policy config argument. + policyConfigFile := filepath.Join(configDir, filename) + args = append(args, "--policy-pack-config", policyConfigFile) + + // Change back to the program directory to proceed with the update. + e.CWD = programDir + } - if len(scenario.WantErrors) == 0 { - t.Log("No errors are expected.") - e.RunCommand(cmd, args...) - } else { - var stdout, stderr string - if scenario.Advisory { - stdout, stderr = e.RunCommand(cmd, args...) - } else { - stdout, stderr = e.RunCommandExpectError(cmd, args...) - } - - for _, wantErr := range scenario.WantErrors { - inSTDOUT := strings.Contains(stdout, wantErr) - inSTDERR := strings.Contains(stderr, wantErr) - - if !inSTDOUT && !inSTDERR { - t.Errorf("Did not find expected error %q", wantErr) + if runtime == Python || hasPythonPack { + cmd = "pipenv" + args = append([]string{"run", "pulumi"}, args...) } - } - if t.Failed() { - t.Logf("Command output:\nSTDOUT:\n%v\n\nSTDERR:\n%v\n\n", stdout, stderr) - } + if len(scenario.WantErrors) == 0 { + t.Log("No errors are expected.") + e.RunCommand(cmd, args...) + } else { + var stdout, stderr string + if scenario.Advisory { + stdout, stderr = e.RunCommand(cmd, args...) + } else { + stdout, stderr = e.RunCommandExpectError(cmd, args...) + } + + for _, wantErr := range scenario.WantErrors { + inSTDOUT := strings.Contains(stdout, wantErr) + inSTDERR := strings.Contains(stderr, wantErr) + + if !inSTDOUT && !inSTDERR { + t.Errorf("Did not find expected error %q", wantErr) + } + } + + if t.Failed() { + t.Logf("Command output:\nSTDOUT:\n%v\n\nSTDERR:\n%v\n\n", stdout, stderr) + } + } + }) } }) } + runScenarios(packDir) + if hasPythonPack { + runScenarios(pythonPackDir) + } e.T = t t.Log("Finished test scenarios.") @@ -239,7 +277,7 @@ func TestValidateResource(t *testing.T) { // Test scenario 3: violates the first policy. { WantErrors: []string{ - "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-1 (a)", + "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-1 (a: pulumi-nodejs:dynamic:Resource)", "Prohibits setting state to 1 on dynamic resources.", "'state' must not have the value 1.", }, @@ -247,7 +285,7 @@ func TestValidateResource(t *testing.T) { // Test scenario 4: violates the second policy. { WantErrors: []string{ - "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-2 (b)", + "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-2 (b: pulumi-nodejs:dynamic:Resource)", "Prohibits setting state to 2 on dynamic resources.", "'state' must not have the value 2.", }, @@ -255,7 +293,7 @@ func TestValidateResource(t *testing.T) { // Test scenario 5: violates the first validation function of the third policy. { WantErrors: []string{ - "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-3-or-4 (c)", + "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-3-or-4 (c: pulumi-nodejs:dynamic:Resource)", "Prohibits setting state to 3 or 4 on dynamic resources.", "'state' must not have the value 3.", }, @@ -263,7 +301,7 @@ func TestValidateResource(t *testing.T) { // Test scenario 6: violates the second validation function of the third policy. { WantErrors: []string{ - "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-3-or-4 (d)", + "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-3-or-4 (d: pulumi-nodejs:dynamic:Resource)", "Prohibits setting state to 3 or 4 on dynamic resources.", "'state' must not have the value 4.", }, @@ -271,7 +309,7 @@ func TestValidateResource(t *testing.T) { // Test scenario 7: violates the fourth policy. { WantErrors: []string{ - "[mandatory] validate-resource-test-policy v0.0.1 randomuuid-no-keepers (r1)", + "[mandatory] validate-resource-test-policy v0.0.1 randomuuid-no-keepers (r1: random:index/randomUuid:RandomUuid)", "Prohibits creating a RandomUuid without any 'keepers'.", "RandomUuid must not have an empty 'keepers'.", }, @@ -283,7 +321,7 @@ func TestValidateResource(t *testing.T) { // Test scenario 9: violates the fifth policy. { WantErrors: []string{ - "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-5 (e)", + "[mandatory] validate-resource-test-policy v0.0.1 dynamic-no-state-with-value-5 (e: pulumi-nodejs:dynamic:Resource)", "Prohibits setting state to 5 on dynamic resources.", "'state' must not have the value 5.", }, @@ -301,7 +339,7 @@ func TestValidatePythonResource(t *testing.T) { // Test scenario 1: violates the policy. { WantErrors: []string{ - "[mandatory] validate-resource-test-policy v0.0.1 randomuuid-no-keepers (r1)", + "[mandatory] validate-resource-test-policy v0.0.1 randomuuid-no-keepers (r1: random:index/randomUuid:RandomUuid)", "Prohibits creating a RandomUuid without any 'keepers'.", "RandomUuid must not have an empty 'keepers'.", }, @@ -343,7 +381,7 @@ func TestValidateStack(t *testing.T) { // Test scenario 5: violates the third policy. { WantErrors: []string{ - "[mandatory] validate-stack-test-policy v0.0.1 dynamic-no-state-with-value-3 (c)", + "[mandatory] validate-stack-test-policy v0.0.1 dynamic-no-state-with-value-3 (c: pulumi-nodejs:dynamic:Resource)", "Prohibits setting state to 3 on dynamic resources.", "'state' must not have the value 3.", }, @@ -382,7 +420,7 @@ func TestUnknownValues(t *testing.T) { }, []policyTestScenario{ { WantErrors: []string{ - "[advisory] unknown-values-policy v0.0.1 unknown-values-resource-validation (pet)", + "[advisory] unknown-values-policy v0.0.1 unknown-values-resource-validation (pet: random:index/randomPet:RandomPet)", "can't run policy 'unknown-values-resource-validation' during preview: string value at .prefix can't be known during preview", "[advisory] unknown-values-policy v0.0.1 unknown-values-stack-validation", "can't run policy 'unknown-values-stack-validation' during preview: string value at .prefix can't be known during preview", @@ -430,7 +468,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 1: Policy Pack: advisory; Policy: advisory. { WantErrors: []string{ - "[advisory] enforcementlevel-advisory-advisory-test-policy v0.0.1 validate-resource (str)", + "[advisory] enforcementlevel-advisory-advisory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[advisory] enforcementlevel-advisory-advisory-test-policy v0.0.1 validate-stack", @@ -446,7 +484,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 3: Policy Pack: advisory; Policy: mandatory. { WantErrors: []string{ - "[mandatory] enforcementlevel-advisory-mandatory-test-policy v0.0.1 validate-resource (str)", + "[mandatory] enforcementlevel-advisory-mandatory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[mandatory] enforcementlevel-advisory-mandatory-test-policy v0.0.1 validate-stack", @@ -457,7 +495,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 4: Policy Pack: advisory; Policy: not set. { WantErrors: []string{ - "[advisory] enforcementlevel-advisory-test-policy v0.0.1 validate-resource (str)", + "[advisory] enforcementlevel-advisory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[advisory] enforcementlevel-advisory-test-policy v0.0.1 validate-stack", @@ -469,7 +507,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 5: Policy Pack: disabled; Policy: advisory. { WantErrors: []string{ - "[advisory] enforcementlevel-disabled-advisory-test-policy v0.0.1 validate-resource (str)", + "[advisory] enforcementlevel-disabled-advisory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[advisory] enforcementlevel-disabled-advisory-test-policy v0.0.1 validate-stack", @@ -485,7 +523,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 7: Policy Pack: disabled; Policy: mandatory. { WantErrors: []string{ - "[mandatory] enforcementlevel-disabled-mandatory-test-policy v0.0.1 validate-resource (str)", + "[mandatory] enforcementlevel-disabled-mandatory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[mandatory] enforcementlevel-disabled-mandatory-test-policy v0.0.1 validate-stack", @@ -500,7 +538,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 9: Policy Pack: mandatory; Policy: advisory. { WantErrors: []string{ - "[advisory] enforcementlevel-mandatory-advisory-test-policy v0.0.1 validate-resource (str)", + "[advisory] enforcementlevel-mandatory-advisory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[advisory] enforcementlevel-mandatory-advisory-test-policy v0.0.1 validate-stack", @@ -516,7 +554,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 11: Policy Pack: mandatory; Policy: mandatory. { WantErrors: []string{ - "[mandatory] enforcementlevel-mandatory-mandatory-test-policy v0.0.1 validate-resource (str)", + "[mandatory] enforcementlevel-mandatory-mandatory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[mandatory] enforcementlevel-mandatory-mandatory-test-policy v0.0.1 validate-stack", @@ -527,7 +565,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 12: Policy Pack: mandatory; Policy: not set. { WantErrors: []string{ - "[mandatory] enforcementlevel-mandatory-test-policy v0.0.1 validate-resource (str)", + "[mandatory] enforcementlevel-mandatory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[mandatory] enforcementlevel-mandatory-test-policy v0.0.1 validate-stack", @@ -538,7 +576,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 13: Policy Pack: not set; Policy: advisory. { WantErrors: []string{ - "[advisory] enforcementlevel-none-advisory-test-policy v0.0.1 validate-resource (str)", + "[advisory] enforcementlevel-none-advisory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[advisory] enforcementlevel-none-advisory-test-policy v0.0.1 validate-stack", @@ -554,7 +592,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 15: Policy Pack: not set; Policy: mandatory. { WantErrors: []string{ - "[mandatory] enforcementlevel-none-mandatory-test-policy v0.0.1 validate-resource (str)", + "[mandatory] enforcementlevel-none-mandatory-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[mandatory] enforcementlevel-none-mandatory-test-policy v0.0.1 validate-stack", @@ -565,7 +603,7 @@ func TestEnforcementLevel(t *testing.T) { // Test scenario 16: Policy Pack: not set; Policy: not set. { WantErrors: []string{ - "[advisory] enforcementlevel-none-test-policy v0.0.1 validate-resource (str)", + "[advisory] enforcementlevel-none-test-policy v0.0.1 validate-resource (str: random:index/randomString:RandomString)", "Always reports a resource violation.", "validate-resource-violation-message", "[advisory] enforcementlevel-none-test-policy v0.0.1 validate-stack", diff --git a/tests/integration/parent_dependencies/policy-pack-python/PulumiPolicy.yaml b/tests/integration/parent_dependencies/policy-pack-python/PulumiPolicy.yaml new file mode 100644 index 0000000..d199d5f --- /dev/null +++ b/tests/integration/parent_dependencies/policy-pack-python/PulumiPolicy.yaml @@ -0,0 +1 @@ +runtime: python diff --git a/tests/integration/parent_dependencies/policy-pack-python/__main__.py b/tests/integration/parent_dependencies/policy-pack-python/__main__.py new file mode 100644 index 0000000..aad7fba --- /dev/null +++ b/tests/integration/parent_dependencies/policy-pack-python/__main__.py @@ -0,0 +1,65 @@ +# Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +import json + +from pulumi_policy import ( + EnforcementLevel, + PolicyPack, + StackValidationPolicy, +) + + +def validate_stack(args, report_violation): + for r in args.resources: + validate(args.resources, r) + + +def validate(resources, r): + stack = next(r for r in resources if r.resource_type == "pulumi:pulumi:Stack") + + t = r.resource_type + if (t == "pulumi:pulumi:Stack" or + t == "pulumi:providers:pulumi-nodejs" or + t == "pulumi:providers:random"): + assert r.parent is None + assert json.dumps(r.dependencies) == json.dumps([]) + assert json.dumps(r.property_dependencies) == json.dumps({}) + elif t == "pulumi-nodejs:dynamic:Resource": + if r.name == "child": + parent = next(r for r in resources if r.name == "parent") + assert r.parent is parent + assert json.dumps(r.dependencies) == json.dumps([]) + assert json.dumps(r.property_dependencies) == json.dumps({}) + elif r.name == "b": + assert r.parent is stack + a = next(r for r in resources if r.name == "a") + assert len(r.dependencies) == 1 + assert r.dependencies[0] is a + assert json.dumps(r.property_dependencies) == json.dumps({}) + elif t == "random:index/randomString:RandomString": + assert r.parent is stack + assert json.dumps(r.dependencies) == json.dumps([]) + assert json.dumps(r.property_dependencies) == json.dumps({}) + elif t == "random:index/randomPet:RandomPet": + assert r.parent is stack + str_ = next(r for r in resources if r.name == "str") + assert len(r.dependencies) == 1 + assert r.dependencies[0] is str_ + assert "prefix" in r.property_dependencies + prefix = r.property_dependencies["prefix"] + assert len(prefix) == 1 + assert prefix[0] is str_ + else: + raise AssertionError(f"Unexpected resource of type: '{t}'.") + +PolicyPack( + name="parent-dependencies-test-policy", + enforcement_level=EnforcementLevel.MANDATORY, + policies=[ + StackValidationPolicy( + name="validate-stack", + description="Validates resource options during `validateStack`.", + validate=validate_stack, + ), + ], +) diff --git a/tests/integration/provider/policy-pack-python/PulumiPolicy.yaml b/tests/integration/provider/policy-pack-python/PulumiPolicy.yaml new file mode 100644 index 0000000..d199d5f --- /dev/null +++ b/tests/integration/provider/policy-pack-python/PulumiPolicy.yaml @@ -0,0 +1 @@ +runtime: python diff --git a/tests/integration/provider/policy-pack-python/__main__.py b/tests/integration/provider/policy-pack-python/__main__.py new file mode 100644 index 0000000..798f6ce --- /dev/null +++ b/tests/integration/provider/policy-pack-python/__main__.py @@ -0,0 +1,70 @@ +# Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +from pulumi import get_project, get_stack + +from pulumi_policy import ( + EnforcementLevel, + PolicyPack, + ResourceValidationPolicy, + StackValidationPolicy, +) + + +def create_urn(type_: str, name: str) -> str: + return f"urn:pulumi:{get_stack()}::{get_project()}::{type_}::{name}" + + +def validate_resource(args, report_violation): + validate(args) + + +def validate_stack(args, report_violation): + for r in args.resources: + validate(r) + + +def validate(r): + t = r.resource_type + if (t == "pulumi:pulumi:Stack" or + t == "pulumi:providers:pulumi-nodejs" or + t == "pulumi:providers:random"): + assert r.provider is None + elif t == "pulumi-nodejs:dynamic:Resource": + assert r.provider is not None + assert r.provider.resource_type == "pulumi:providers:pulumi-nodejs" + assert r.provider.name == "default" + assert r.provider.urn == create_urn("pulumi:providers:pulumi-nodejs", "default") + assert not r.provider.props + elif t == "random:index/randomUuid:RandomUuid": + assert r.provider is not None + assert r.provider.resource_type == "pulumi:providers:random" + assert r.provider.name == "default_1_5_0" + assert r.provider.urn == create_urn("pulumi:providers:random", "default_1_5_0") + assert r.provider.props + assert r.provider.props["version"] == "1.5.0" + elif t == "random:index/randomString:RandomString": + assert r.provider is not None + assert r.provider.resource_type == "pulumi:providers:random" + assert r.provider.name == "my-provider" + assert r.provider.urn == create_urn("pulumi:providers:random", "my-provider") + assert not r.provider.props + else: + raise AssertionError(f"Unexpected resource of type: '{t}'.") + + +PolicyPack( + name="resource-options-test-policy", + enforcement_level=EnforcementLevel.MANDATORY, + policies=[ + ResourceValidationPolicy( + name="validate-resource", + description="Validates resource options during `validateResource`.", + validate=validate_resource, + ), + StackValidationPolicy( + name="validate-stack", + description="Validates resource options during `validateStack`.", + validate=validate_stack, + ), + ], +) diff --git a/tests/integration/provider/policy-pack-python/requirements.txt b/tests/integration/provider/policy-pack-python/requirements.txt new file mode 100644 index 0000000..0081530 --- /dev/null +++ b/tests/integration/provider/policy-pack-python/requirements.txt @@ -0,0 +1 @@ +pulumi>=1.0.0 diff --git a/tests/integration/resource_options/policy-pack-python/PulumiPolicy.yaml b/tests/integration/resource_options/policy-pack-python/PulumiPolicy.yaml new file mode 100644 index 0000000..d199d5f --- /dev/null +++ b/tests/integration/resource_options/policy-pack-python/PulumiPolicy.yaml @@ -0,0 +1 @@ +runtime: python diff --git a/tests/integration/resource_options/policy-pack-python/__main__.py b/tests/integration/resource_options/policy-pack-python/__main__.py new file mode 100644 index 0000000..9a2f6ec --- /dev/null +++ b/tests/integration/resource_options/policy-pack-python/__main__.py @@ -0,0 +1,203 @@ +# Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +from pulumi import Config, get_project, get_stack + +from pulumi_policy import ( + EnforcementLevel, + PolicyCustomTimeouts, + PolicyPack, + PolicyResourceOptions, + ResourceValidationPolicy, + StackValidationPolicy, +) + + +def create_urn(type_: str, name: str) -> str: + return f"urn:pulumi:{get_stack()}::{get_project()}::{type_}::{name}" + + +def validate_resource(args, report_violation): + validate(args) + + +def validate_stack(args, report_violation): + for r in args.resources: + validate(r) + + +def validate(r): + config = Config() + test_scenario = config.require_int("scenario") + + # We only validate during the first test scenario. The subsequent test scenario is only + # used to unprotect protected resources in preparation for destroying the stack. + if test_scenario != 1: + return + + t = r.resource_type + if (t == "pulumi:pulumi:Stack" or + t == "pulumi:providers:pulumi-nodejs" or + t == "pulumi:providers:random"): + assert options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + elif t == "pulumi-nodejs:dynamic:Resource": + validate_dynamic_resource(r) + elif t == "random:index/randomUuid:RandomUuid": + assert options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + else: + raise AssertionError(f"Unexpected resource of type: '{t}'.") + + +def validate_dynamic_resource(r): + if r.name == "empty" or r.name == "parent" or r.name == "a": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + elif r.name == "protect": + options_equal(PolicyResourceOptions( + protect=True, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + elif r.name == "ignoreChanges": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=["foo", "bar"], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + elif r.name == "deleteBeforeReplaceNotSet": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + elif r.name == "deleteBeforeReplaceTrue": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=True, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + elif r.name == "deleteBeforeReplaceFalse": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=False, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + elif r.name == "aliased": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[create_urn("pulumi-nodejs:dynamic:Resource", "old-name-for-aliased")], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + elif r.name == "timeouts": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(60, 120, 180), + ), r.opts) + elif r.name == "timeouts-create": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(240, 0, 0), + ), r.opts) + elif r.name == "timeouts-update": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 300, 0), + ), r.opts) + elif r.name == "timeouts-delete": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=[], + custom_timeouts=PolicyCustomTimeouts(0, 0, 360), + ), r.opts) + elif r.name == "secrets": + options_equal(PolicyResourceOptions( + protect=False, + ignore_changes=[], + delete_before_replace=None, + aliases=[], + additional_secret_outputs=["foo"], + custom_timeouts=PolicyCustomTimeouts(0, 0, 0), + ), r.opts) + else: + raise AssertionError(f"Unexpected resource with name: '{r.name}'.") + + +def options_equal(expected: PolicyResourceOptions, actual: PolicyResourceOptions) -> bool: + return (expected.protect == actual.protect and + expected.ignore_changes == actual.ignore_changes and + expected.delete_before_replace == actual.delete_before_replace and + expected.aliases == actual.aliases and + expected.custom_timeouts.create_seconds == actual.custom_timeouts.create_seconds and + expected.custom_timeouts.update_seconds == actual.custom_timeouts.update_seconds and + expected.custom_timeouts.delete_seconds == actual.custom_timeouts.delete_seconds and + expected.additional_secret_outputs == actual.additional_secret_outputs) + + +PolicyPack( + name="resource-options-test-policy", + enforcement_level=EnforcementLevel.MANDATORY, + policies=[ + ResourceValidationPolicy( + name="validate-resource", + description="Validates resource options during `validateResource`.", + validate=validate_resource, + ), + StackValidationPolicy( + name="validate-stack", + description="Validates resource options during `validateStack`.", + validate=validate_stack, + ), + ], +) diff --git a/tests/integration/resource_options/policy-pack-python/requirements.txt b/tests/integration/resource_options/policy-pack-python/requirements.txt new file mode 100644 index 0000000..0081530 --- /dev/null +++ b/tests/integration/resource_options/policy-pack-python/requirements.txt @@ -0,0 +1 @@ +pulumi>=1.0.0 diff --git a/tests/integration/runtime_data/policy-pack-python/PulumiPolicy.yaml b/tests/integration/runtime_data/policy-pack-python/PulumiPolicy.yaml new file mode 100644 index 0000000..d199d5f --- /dev/null +++ b/tests/integration/runtime_data/policy-pack-python/PulumiPolicy.yaml @@ -0,0 +1 @@ +runtime: python diff --git a/tests/integration/runtime_data/policy-pack-python/__main__.py b/tests/integration/runtime_data/policy-pack-python/__main__.py new file mode 100644 index 0000000..e3897fd --- /dev/null +++ b/tests/integration/runtime_data/policy-pack-python/__main__.py @@ -0,0 +1,69 @@ +# Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +import json +import os + +from pulumi import Config, get_project, get_stack +from pulumi.runtime import is_dry_run +from pulumi.runtime.config import CONFIG +from pulumi_aws import config as aws_config + +from pulumi_policy import ( + EnforcementLevel, + PolicyPack, + ResourceValidationPolicy, + StackValidationPolicy, +) + + +def validate_resource(args, report_violation): + verify_data(args) + + +def validate_stack(args, report_violation): + for r in args.resources: + verify_data(r) + + +def verify_data(r): + t = r.resource_type + if t != "pulumi-nodejs:dynamic:Resource": + return + + # Verify is_dry_run() + assert is_dry_run() == r.props["isDryRun"] + + # Verify get_project() + assert "PULUMI_TEST_PROJECT" in os.environ + assert get_project() == os.environ["PULUMI_TEST_PROJECT"] + assert get_project() == r.props["getProject"] + + # Verify get_stack() + assert "PULUMI_TEST_STACK" in os.environ + assert get_stack() == os.environ["PULUMI_TEST_STACK"] + assert get_stack() == r.props["getStack"] + + # Verify Config + assert json.dumps(CONFIG, sort_keys=True) == json.dumps(r.props["allConfig"], sort_keys=True) + config = Config() + value = config.require("aConfigValue") + assert value == "this value is a value" + assert aws_config.region == "us-west-2" + + +PolicyPack( + name="runtime-data-policy", + enforcement_level=EnforcementLevel.MANDATORY, + policies=[ + ResourceValidationPolicy( + name="runtime-data-resource-validation", + description="Verifies runtime data during resource validation.", + validate=validate_resource, + ), + StackValidationPolicy( + name="runtime-data-stack-validation", + description="Verifies runtime data during stack validation.", + validate=validate_stack, + ), + ], +) diff --git a/tests/integration/runtime_data/policy-pack-python/requirements.txt b/tests/integration/runtime_data/policy-pack-python/requirements.txt new file mode 100644 index 0000000..9a6a9d1 --- /dev/null +++ b/tests/integration/runtime_data/policy-pack-python/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=1.0.0 +pulumi_aws>=1.0.0 diff --git a/tests/integration/validate_python_resource/policy-pack-python/PulumiPolicy.yaml b/tests/integration/validate_python_resource/policy-pack-python/PulumiPolicy.yaml new file mode 100644 index 0000000..d199d5f --- /dev/null +++ b/tests/integration/validate_python_resource/policy-pack-python/PulumiPolicy.yaml @@ -0,0 +1 @@ +runtime: python diff --git a/tests/integration/validate_python_resource/policy-pack-python/__main__.py b/tests/integration/validate_python_resource/policy-pack-python/__main__.py new file mode 100644 index 0000000..2c1f0c2 --- /dev/null +++ b/tests/integration/validate_python_resource/policy-pack-python/__main__.py @@ -0,0 +1,27 @@ +# Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +from pulumi_policy import ( + EnforcementLevel, + PolicyPack, + ReportViolation, + ResourceValidationArgs, + ResourceValidationPolicy, +) + +def randomuuid_no_keepers_validator(args: ResourceValidationArgs, report_violation: ReportViolation): + if args.resource_type == "random:index/randomUuid:RandomUuid": + if "keepers" not in args.props or not args.props["keepers"]: + report_violation("RandomUuid must not have an empty 'keepers'.") + + +randomuuid_no_keepers = ResourceValidationPolicy( + name="randomuuid-no-keepers", + description="Prohibits creating a RandomUuid without any 'keepers'.", + validate=randomuuid_no_keepers_validator, +) + +PolicyPack( + name="validate-resource-test-policy", + enforcement_level=EnforcementLevel.MANDATORY, + policies=[randomuuid_no_keepers], +) diff --git a/tests/integration/validate_resource/policy-pack-python/PulumiPolicy.yaml b/tests/integration/validate_resource/policy-pack-python/PulumiPolicy.yaml new file mode 100644 index 0000000..d199d5f --- /dev/null +++ b/tests/integration/validate_resource/policy-pack-python/PulumiPolicy.yaml @@ -0,0 +1 @@ +runtime: python diff --git a/tests/integration/validate_resource/policy-pack-python/__main__.py b/tests/integration/validate_resource/policy-pack-python/__main__.py new file mode 100644 index 0000000..770ad25 --- /dev/null +++ b/tests/integration/validate_resource/policy-pack-python/__main__.py @@ -0,0 +1,76 @@ +# Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +from pulumi_policy import ( + EnforcementLevel, + PolicyPack, + ReportViolation, + ResourceValidationArgs, + ResourceValidationPolicy, +) + +def dynamic_no_state_with_value_1(args: ResourceValidationArgs, report_violation: ReportViolation): + if args.resource_type == "pulumi-nodejs:dynamic:Resource": + if "state" in args.props and args.props["state"] == 1: + report_violation("'state' must not have the value 1.") + +def dynamic_no_state_with_value_2(args: ResourceValidationArgs, report_violation: ReportViolation): + if args.resource_type == "pulumi-nodejs:dynamic:Resource": + if "state" in args.props and args.props["state"] == 2: + report_violation("'state' must not have the value 2.") + +def dynamic_no_state_with_value_3(args: ResourceValidationArgs, report_violation: ReportViolation): + if args.resource_type == "pulumi-nodejs:dynamic:Resource": + if "state" in args.props and args.props["state"] == 3: + report_violation("'state' must not have the value 3.") + +def dynamic_no_state_with_value_4(args: ResourceValidationArgs, report_violation: ReportViolation): + if args.resource_type == "pulumi-nodejs:dynamic:Resource": + if "state" in args.props and args.props["state"] == 4: + report_violation("'state' must not have the value 4.") + +# Note: In the NodeJS Policy Pack, this is a strongly-typed policy, but since Python +# does not yet support filtering by type, this is checking the type directly. +def randomuuid_no_keepers(args: ResourceValidationArgs, report_violation: ReportViolation): + if args.resource_type == "random:index/randomUuid:RandomUuid": + if "keepers" not in args.props or not args.props["keepers"]: + report_violation("RandomUuid must not have an empty 'keepers'.") + +def dynamic_no_state_with_value_5(args: ResourceValidationArgs, report_violation: ReportViolation): + if args.resource_type == "pulumi-nodejs:dynamic:Resource": + if "state" in args.props and args.props["state"] == 5: + report_violation("'state' must not have the value 5.", "some-urn") + +PolicyPack( + name="validate-resource-test-policy", + enforcement_level=EnforcementLevel.MANDATORY, + policies=[ + ResourceValidationPolicy( + name="dynamic-no-state-with-value-1", + description="Prohibits setting state to 1 on dynamic resources.", + validate=dynamic_no_state_with_value_1, + ), + ResourceValidationPolicy( + name="dynamic-no-state-with-value-2", + description="Prohibits setting state to 2 on dynamic resources.", + validate=dynamic_no_state_with_value_2, + ), + ResourceValidationPolicy( + name="dynamic-no-state-with-value-3-or-4", + description="Prohibits setting state to 3 or 4 on dynamic resources.", + validate=[ + dynamic_no_state_with_value_3, + dynamic_no_state_with_value_4, + ], + ), + ResourceValidationPolicy( + name="randomuuid-no-keepers", + description="Prohibits creating a RandomUuid without any 'keepers'.", + validate=randomuuid_no_keepers, + ), + ResourceValidationPolicy( + name="dynamic-no-state-with-value-5", + description="Prohibits setting state to 5 on dynamic resources.", + validate=dynamic_no_state_with_value_5, + ), + ], +) diff --git a/tests/integration/validate_stack/policy-pack-python/PulumiPolicy.yaml b/tests/integration/validate_stack/policy-pack-python/PulumiPolicy.yaml new file mode 100644 index 0000000..d199d5f --- /dev/null +++ b/tests/integration/validate_stack/policy-pack-python/PulumiPolicy.yaml @@ -0,0 +1 @@ +runtime: python diff --git a/tests/integration/validate_stack/policy-pack-python/__main__.py b/tests/integration/validate_stack/policy-pack-python/__main__.py new file mode 100644 index 0000000..da2e0d3 --- /dev/null +++ b/tests/integration/validate_stack/policy-pack-python/__main__.py @@ -0,0 +1,75 @@ +# Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +from pulumi_policy import ( + EnforcementLevel, + PolicyPack, + ReportViolation, + StackValidationArgs, + StackValidationPolicy, +) + +def dynamic_no_state_with_value_1(args: StackValidationArgs, report_violation: ReportViolation): + for r in args.resources: + if r.resource_type == "pulumi-nodejs:dynamic:Resource": + if "state" in r.props and r.props["state"] == 1: + report_violation("'state' must not have the value 1.") + +def dynamic_no_state_with_value_2(args: StackValidationArgs, report_violation: ReportViolation): + for r in args.resources: + if r.resource_type == "pulumi-nodejs:dynamic:Resource": + if "state" in r.props and r.props["state"] == 2: + report_violation("'state' must not have the value 2.") + +def dynamic_no_state_with_value_3(args: StackValidationArgs, report_violation: ReportViolation): + for r in args.resources: + if r.resource_type == "pulumi-nodejs:dynamic:Resource": + if "state" in r.props and r.props["state"] == 3: + report_violation("'state' must not have the value 3.", r.urn) + +# Note: In the NodeJS Policy Pack, this is a strongly-typed policy, but since Python +# does not yet support filtering by type, this is checking the type directly. +def randomuuid_no_keepers(args: StackValidationArgs, report_violation: ReportViolation): + for r in args.resources: + if r.resource_type == "random:index/randomUuid:RandomUuid": + if "keepers" not in r.props or not r.props["keepers"]: + report_violation("RandomUuid must not have an empty 'keepers'.") + +# Note: In the NodeJS Policy Pack, this uses the `isType` helper. +def no_randomstrings(args: StackValidationArgs, report_violation: ReportViolation): + for r in args.resources: + if r.resource_type == "random:index/randomString:RandomString": + report_violation("RandomString resources are not allowed.") + +PolicyPack( + name="validate-stack-test-policy", + enforcement_level=EnforcementLevel.MANDATORY, + policies=[ + StackValidationPolicy( + name="dynamic-no-state-with-value-1", + description="Prohibits setting state to 1 on dynamic resources.", + validate=dynamic_no_state_with_value_1, + ), + # More than one policy. + StackValidationPolicy( + name="dynamic-no-state-with-value-2", + description="Prohibits setting state to 2 on dynamic resources.", + validate=dynamic_no_state_with_value_2, + ), + # Policy that specifies the URN of the resource violating the policy. + StackValidationPolicy( + name="dynamic-no-state-with-value-3", + description="Prohibits setting state to 3 on dynamic resources.", + validate=dynamic_no_state_with_value_3, + ), + StackValidationPolicy( + name="randomuuid-no-keepers", + description="Prohibits creating a RandomUuid without any 'keepers'.", + validate=randomuuid_no_keepers, + ), + StackValidationPolicy( + name="no-randomstrings", + description="Prohibits RandomString resources.", + validate=no_randomstrings, + ), + ], +)