Provide Kubernetes resource validation to k8s module (#43352)

* Provide Kubernetes resource validation to k8s module

Use kubernetes-validate to validate Kubernetes resource
definitions against the published schema

* Additional tests for kubernetes-validate

* Improve k8s error messages on exceptions

Parse the response body for the message rather than returning
a JSON blob

If we've validated and there are warnings, return those too - they
can be more helpful

```
"msg": "Failed to patch object: {\"kind\":\"Status\",\"apiVersion\":\"v1\",\"metadata\":{},
       \"status\":\"Failure\",\"message\":\"[pos 334]: json: decNum: got first char 'h'\",\"code\":500}\n",
```
vs
```
"msg": "Failed to patch object: [pos 334]: json: decNum: got first char 'h'\nresource
        validation error at spec.replicas: 'hello' is not of type u'integer'",
```

* Update versions used

In particular openshift/origin:3.9.0

* Add changelog for k8s validate change
pull/48836/head
Will Thames 6 years ago committed by John R Barker
parent ae0054a79e
commit aaf29c785f

@ -0,0 +1,2 @@
minor_changes:
- k8s - add validate parameter to k8s module to allow resources to be validated against their specification

@ -20,6 +20,7 @@ from __future__ import absolute_import, division, print_function
import copy import copy
from datetime import datetime from datetime import datetime
from distutils.version import LooseVersion
import time import time
import sys import sys
@ -31,14 +32,28 @@ from ansible.module_utils.common.dict_transformations import dict_merge
try: try:
import yaml import yaml
from openshift.dynamic.exceptions import DynamicApiError, NotFoundError, ConflictError, ForbiddenError from openshift.dynamic.exceptions import DynamicApiError, NotFoundError, ConflictError, ForbiddenError, KubernetesValidateMissing
except ImportError: except ImportError:
# Exceptions handled in common # Exceptions handled in common
pass pass
try:
import kubernetes_validate
HAS_KUBERNETES_VALIDATE = True
except ImportError:
HAS_KUBERNETES_VALIDATE = False
class KubernetesRawModule(KubernetesAnsibleModule): class KubernetesRawModule(KubernetesAnsibleModule):
@property
def validate_spec(self):
return dict(
fail_on_error=dict(type='bool'),
version=dict(),
strict=dict(type='bool', default=True)
)
@property @property
def argspec(self): def argspec(self):
argument_spec = copy.deepcopy(COMMON_ARG_SPEC) argument_spec = copy.deepcopy(COMMON_ARG_SPEC)
@ -46,6 +61,7 @@ class KubernetesRawModule(KubernetesAnsibleModule):
argument_spec['merge_type'] = dict(type='list', choices=['json', 'merge', 'strategic-merge']) argument_spec['merge_type'] = dict(type='list', choices=['json', 'merge', 'strategic-merge'])
argument_spec['wait'] = dict(type='bool', default=False) argument_spec['wait'] = dict(type='bool', default=False)
argument_spec['wait_timeout'] = dict(type='int', default=120) argument_spec['wait_timeout'] = dict(type='int', default=120)
argument_spec['validate'] = dict(type='dict', default=None, options=self.validate_spec)
return argument_spec return argument_spec
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -59,12 +75,17 @@ class KubernetesRawModule(KubernetesAnsibleModule):
mutually_exclusive=mutually_exclusive, mutually_exclusive=mutually_exclusive,
supports_check_mode=True, supports_check_mode=True,
**kwargs) **kwargs)
self.kind = self.params.get('kind')
self.kind = self.params.pop('kind') self.api_version = self.params.get('api_version')
self.api_version = self.params.pop('api_version') self.name = self.params.get('name')
self.name = self.params.pop('name') self.namespace = self.params.get('namespace')
self.namespace = self.params.pop('namespace') resource_definition = self.params.get('resource_definition')
resource_definition = self.params.pop('resource_definition') if self.params['validate']:
if LooseVersion(self.openshift_version) < LooseVersion("0.8.0"):
self.fail_json(msg="openshift >= 0.8.0 is required for validate")
if self.params['merge_type']:
if LooseVersion(self.openshift_version) < LooseVersion("0.6.2"):
self.fail_json(msg="openshift >= 0.6.2 is required for merge_type")
if resource_definition: if resource_definition:
if isinstance(resource_definition, string_types): if isinstance(resource_definition, string_types):
try: try:
@ -101,7 +122,11 @@ class KubernetesRawModule(KubernetesAnsibleModule):
api_version = definition.get('apiVersion', self.api_version) api_version = definition.get('apiVersion', self.api_version)
resource = self.find_resource(search_kind, api_version, fail=True) resource = self.find_resource(search_kind, api_version, fail=True)
definition = self.set_defaults(resource, definition) definition = self.set_defaults(resource, definition)
self.warnings = []
if self.params['validate'] is not None:
self.warnings = self.validate(definition)
result = self.perform_action(resource, definition) result = self.perform_action(resource, definition)
result['warnings'] = self.warnings
changed = changed or result['changed'] changed = changed or result['changed']
results.append(result) results.append(result)
@ -115,6 +140,17 @@ class KubernetesRawModule(KubernetesAnsibleModule):
} }
}) })
def validate(self, resource):
try:
warnings, errors = self.client.validate(resource, self.params['validate'].get('version'), self.params['validate'].get('strict'))
except KubernetesValidateMissing:
self.fail_json(msg="kubernetes-validate python library is required to validate resources")
if errors and self.params['validate']['fail_on_error']:
self.fail_json(msg="\n".join(errors))
else:
return warnings + errors
def set_defaults(self, resource, definition): def set_defaults(self, resource, definition):
definition['kind'] = resource.kind definition['kind'] = resource.kind
definition['apiVersion'] = resource.group_version definition['apiVersion'] = resource.group_version
@ -198,8 +234,10 @@ class KubernetesRawModule(KubernetesAnsibleModule):
if the resource you are creating does not directly create a resource of the same kind.".format(name)) if the resource you are creating does not directly create a resource of the same kind.".format(name))
return result return result
except DynamicApiError as exc: except DynamicApiError as exc:
self.fail_json(msg="Failed to create object: {0}".format(exc.body), msg = "Failed to create object: {0}".format(exc.body)
error=exc.status, status=exc.status, reason=exc.reason, definition=definition) if self.warnings:
msg += "\n" + "\n ".join(self.warnings)
self.fail_json(msg=msg, error=exc.status, status=exc.status, reason=exc.reason)
success = True success = True
result['result'] = k8s_obj result['result'] = k8s_obj
if wait: if wait:
@ -220,8 +258,11 @@ class KubernetesRawModule(KubernetesAnsibleModule):
try: try:
k8s_obj = resource.replace(definition, name=name, namespace=namespace).to_dict() k8s_obj = resource.replace(definition, name=name, namespace=namespace).to_dict()
except DynamicApiError as exc: except DynamicApiError as exc:
self.fail_json(msg="Failed to replace object: {0}".format(exc.body), msg = "Failed to replace object: {0}".format(exc.body)
error=exc.status, status=exc.status, reason=exc.reason) if self.warnings:
msg += "\n" + "\n ".join(self.warnings)
self.fail_json(msg=msg, error=exc.status, status=exc.status, reason=exc.reason)
match, diffs = self.diff_objects(existing.to_dict(), k8s_obj)
success = True success = True
result['result'] = k8s_obj result['result'] = k8s_obj
if wait: if wait:
@ -238,11 +279,7 @@ class KubernetesRawModule(KubernetesAnsibleModule):
if self.check_mode: if self.check_mode:
k8s_obj = dict_merge(existing.to_dict(), definition) k8s_obj = dict_merge(existing.to_dict(), definition)
else: else:
from distutils.version import LooseVersion
if LooseVersion(self.openshift_version) < LooseVersion("0.6.2"): if LooseVersion(self.openshift_version) < LooseVersion("0.6.2"):
if self.params['merge_type']:
self.fail_json(msg="openshift >= 0.6.2 is required for merge_type")
else:
k8s_obj, error = self.patch_resource(resource, definition, existing, name, k8s_obj, error = self.patch_resource(resource, definition, existing, name,
namespace) namespace)
else: else:
@ -278,8 +315,10 @@ class KubernetesRawModule(KubernetesAnsibleModule):
error = {} error = {}
return k8s_obj, {} return k8s_obj, {}
except DynamicApiError as exc: except DynamicApiError as exc:
error = dict(msg="Failed to patch object: {0}".format(exc.body), msg = "Failed to patch object: {0}".format(exc.body)
error=exc.status, status=exc.status, reason=exc.reason) if self.warnings:
msg += "\n" + "\n ".join(self.warnings)
error = dict(msg=msg, error=exc.status, status=exc.status, reason=exc.reason, warnings=self.warnings)
return None, error return None, error
def create_project_request(self, definition): def create_project_request(self, definition):

@ -73,6 +73,22 @@ options:
- How long in seconds to wait for the resource to end up in the desired state. Ignored if C(wait) is not set. - How long in seconds to wait for the resource to end up in the desired state. Ignored if C(wait) is not set.
default: 120 default: 120
version_added: "2.8" version_added: "2.8"
validate:
description:
- how (if at all) to validate the resource definition against the kubernetes schema.
Requires the kubernetes-validate python module
suboptions:
fail_on_error:
description: whether to fail on validation errors.
required: yes
type: bool
version:
description: version of Kubernetes to validate against. defaults to Kubernetes server version
strict:
description: whether to fail when passing unexpected properties
default: no
type: bool
version_added: "2.8"
requirements: requirements:
- "python >= 2.7" - "python >= 2.7"
@ -141,6 +157,21 @@ EXAMPLES = '''
k8s: k8s:
state: present state: present
definition: "{{ lookup('template', '/testing/deployment.yml') }}" definition: "{{ lookup('template', '/testing/deployment.yml') }}"
- name: fail on validation errors
k8s:
state: present
definition: "{{ lookup('template', '/testing/deployment.yml') }}"
validate:
fail_on_error: yes
- name: warn on validation errors, check for unexpected properties
k8s:
state: present
definition: "{{ lookup('template', '/testing/deployment.yml') }}"
validate:
fail_on_error: no
strict: yes
''' '''
RETURN = ''' RETURN = '''

@ -0,0 +1,61 @@
- hosts: localhost
connection: local
gather_facts: no
vars:
ansible_python_interpreter: "{{ ansible_playbook_python }}"
recreate_crd_default_merge_expectation: recreate_crd is failed
playbook_namespace: ansible-test-k8s-older-openshift
tasks:
- python_requirements_facts:
dependencies:
- openshift==0.6.0
- kubernetes==6.0.0
# append_hash
- name: use append_hash with ConfigMap
k8s:
definition:
metadata:
name: config-map-test
namespace: "{{ playbook_namespace }}"
apiVersion: v1
kind: ConfigMap
data:
hello: world
append_hash: yes
ignore_errors: yes
register: k8s_append_hash
- name: assert that append_hash fails gracefully
assert:
that:
- k8s_append_hash is failed
- "k8s_append_hash.msg == 'openshift >= 0.7.FIXME is required for append_hash'"
# merge_type
- include_role:
name: k8s
tasks_from: crd
# validate
- name: attempt to use validate with older openshift
k8s:
definition:
metadata:
name: config-map-test
namespace: "{{ playbook_namespace }}"
apiVersion: v1
kind: ConfigMap
data:
hello: world
validate:
fail_on_error: yes
ignore_errors: yes
register: k8s_validate
- name: assert that validate fails gracefully
assert:
that:
- k8s_validate is failed
- "k8s_validate.msg == 'openshift >= 0.7.FIXME is required for validate'"

@ -0,0 +1,21 @@
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
labels:
app: kuard
name: kuard
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: kuard
unwanted: value
template:
metadata:
labels:
app: kuard
spec:
containers:
- image: gcr.io/kuar-demo/kuard-amd64:1
name: kuard

@ -0,0 +1,20 @@
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
labels:
app: kuard
name: kuard
namespace: default
spec:
replicas: hello
selector:
matchLabels:
app: kuard
template:
metadata:
labels:
app: kuard
spec:
containers:
- image: gcr.io/kuar-demo/kuard-amd64:1
name: kuard

@ -0,0 +1,117 @@
- block:
- name: Create a namespace
k8s:
name: "{{ playbook_namespace }}"
kind: namespace
- name: incredibly simple ConfigMap
k8s:
definition:
apiVersion: v1
kind: ConfigMap
metadata:
name: hello
namespace: "{{ playbook_namespace }}"
validate:
fail_on_error: yes
register: k8s_with_validate
- name: assert that k8s_with_validate succeeds
assert:
that:
- k8s_with_validate is successful
- name: extra property does not fail without strict
k8s:
src: "{{ role_path }}/files/kuard-extra-property.yml"
namespace: "{{ playbook_namespace }}"
validate:
fail_on_error: yes
strict: no
- name: extra property fails with strict
k8s:
src: "{{ role_path }}/files/kuard-extra-property.yml"
namespace: "{{ playbook_namespace }}"
validate:
fail_on_error: yes
strict: yes
ignore_errors: yes
register: extra_property
- name: check that extra property fails with strict
assert:
that:
- extra_property is failed
- name: invalid type fails at validation stage
k8s:
src: "{{ role_path }}/files/kuard-invalid-type.yml"
namespace: "{{ playbook_namespace }}"
validate:
fail_on_error: yes
strict: no
ignore_errors: yes
register: invalid_type
- name: check that invalid type fails
assert:
that:
- invalid_type is failed
- name: invalid type fails with warnings when fail_on_error is False
k8s:
src: "{{ role_path }}/files/kuard-invalid-type.yml"
namespace: "{{ playbook_namespace }}"
validate:
fail_on_error: no
strict: no
ignore_errors: yes
register: invalid_type_no_fail
- name: check that invalid type fails
assert:
that:
- invalid_type_no_fail is failed
- name: setup custom resource definition
k8s:
src: "{{ role_path }}/files/setup-crd.yml"
- name: add custom resource definition
k8s:
src: "{{ role_path }}/files/crd-resource.yml"
namespace: "{{ playbook_namespace }}"
validate:
fail_on_error: yes
strict: yes
register: unknown_kind
- name: check that unknown kind warns
assert:
that:
- unknown_kind is successful
- "'warnings' in unknown_kind"
always:
- name: remove custom resource
k8s:
definition: "{{ lookup('file', role_path + '/files/crd-resource.yml') }}"
namespace: "{{ playbook_namespace }}"
state: absent
ignore_errors: yes
- name: remove custom resource definitions
k8s:
definition: "{{ lookup('file', role_path + '/files/setup-crd.yml') }}"
state: absent
- name: Delete namespace
k8s:
state: absent
definition:
- kind: Namespace
apiVersion: v1
metadata:
name: "{{ playbook_namespace }}"
ignore_errors: yes

@ -0,0 +1,9 @@
- hosts: localhost
connection: local
vars:
playbook_namespace: ansible-test-k8s-validate
tasks:
- include_role:
name: k8s
tasks_from: validate_installed

@ -0,0 +1,21 @@
- hosts: localhost
connection: local
tasks:
- k8s:
definition:
apiVersion: v1
kind: ConfigMap
metadata:
name: hello
namespace: default
validate:
fail_on_error: yes
ignore_errors: yes
register: k8s_no_validate
- name: assert that k8s_no_validate fails gracefully
assert:
that:
- k8s_no_validate is failed
- "k8s_no_validate.msg == 'kubernetes-validate python library is required to validate resources'"

@ -12,14 +12,26 @@ MYTMPDIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir')
# but for the python3 tests we need virtualenv to use python3 # but for the python3 tests we need virtualenv to use python3
PYTHON=${ANSIBLE_TEST_PYTHON_INTERPRETER:-python} PYTHON=${ANSIBLE_TEST_PYTHON_INTERPRETER:-python}
# Test graceful failure for missing kubernetes-validate
virtualenv --system-site-packages --python "${PYTHON}" "${MYTMPDIR}/openshift-validate-not-installed"
source "${MYTMPDIR}/openshift-validate-not-installed/bin/activate"
$PYTHON -m pip install openshift==0.8.1
ansible-playbook -v playbooks/validate_not_installed.yml "$@"
# Test validate with kubernetes-validate
virtualenv --system-site-packages --python "${PYTHON}" "${MYTMPDIR}/openshift-validate-installed"
source "${MYTMPDIR}/openshift-validate-installed/bin/activate"
$PYTHON -m pip install openshift==0.8.1 kubernetes-validate==1.12.0
ansible-playbook -v playbooks/validate_installed.yml "$@"
# Test graceful failure for older versions of openshift # Test graceful failure for older versions of openshift
virtualenv --system-site-packages --python "${PYTHON}" "${MYTMPDIR}/openshift-0.6.0" virtualenv --system-site-packages --python "${PYTHON}" "${MYTMPDIR}/openshift-0.6.0"
source "${MYTMPDIR}/openshift-0.6.0/bin/activate" source "${MYTMPDIR}/openshift-0.6.0/bin/activate"
$PYTHON -m pip install 'openshift==0.6.0' 'kubernetes==6.0.0' $PYTHON -m pip install openshift==0.6.0 kubernetes==6.0.0
ansible-playbook -v playbooks/merge_type_fail.yml "$@" ansible-playbook -v playbooks/merge_type_fail.yml "$@"
# Run full test suite # Run full test suite
virtualenv --system-site-packages --python "${PYTHON}" "${MYTMPDIR}/openshift-recent" virtualenv --system-site-packages --python "${PYTHON}" "${MYTMPDIR}/openshift-recent"
source "${MYTMPDIR}/openshift-recent/bin/activate" source "${MYTMPDIR}/openshift-recent/bin/activate"
$PYTHON -m pip install 'openshift==0.7.2' $PYTHON -m pip install openshift==0.8.1
ansible-playbook -v playbooks/full_test.yml "$@" ansible-playbook -v playbooks/full_test.yml "$@"

@ -44,7 +44,7 @@ class OpenShiftCloudProvider(CloudProvider):
super(OpenShiftCloudProvider, self).__init__(args, config_extension='.kubeconfig') super(OpenShiftCloudProvider, self).__init__(args, config_extension='.kubeconfig')
# The image must be pinned to a specific version to guarantee CI passes with the version used. # The image must be pinned to a specific version to guarantee CI passes with the version used.
self.image = 'openshift/origin:v3.7.1' self.image = 'openshift/origin:v3.9.0'
self.container_name = '' self.container_name = ''
def filter(self, targets, exclude): def filter(self, targets, exclude):

Loading…
Cancel
Save