Add openshift_route module for route creation (#40)

* first draft of interface

* Add basic implementation

* Add validation

* rename to openshift_route and add some test tasks

* Fix sanity checks

* Add checks for missing dependencies

* Add port processing like the oc command

* Add real tests

* Fix waiting

* add some more waiting to test

* add state parameters and fix RETURN docs

* try to fix odd sanity issue

* import tests passing

* Fix all sanity tests

* Do less work when state is absent, and add explicit removal values

* insecure_policy disable -> disallow

* add proper default for insecure_policy
This commit is contained in:
Fabian von Feilitzsch
2020-10-05 15:03:03 -04:00
committed by GitHub
parent 6942cd6756
commit e7c3351309
3 changed files with 785 additions and 0 deletions

View File

@@ -0,0 +1,240 @@
---
- name: Create Deployment
community.okd.k8s:
wait: yes
definition:
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-kubernetes
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: hello-kubernetes
template:
metadata:
labels:
app: hello-kubernetes
spec:
containers:
- name: hello-kubernetes
image: docker.io/openshift/hello-openshift
ports:
- containerPort: 8080
- name: Create Service
community.okd.k8s:
wait: yes
definition:
apiVersion: v1
kind: Service
metadata:
name: hello-kubernetes
namespace: default
spec:
ports:
- port: 80
targetPort: 8080
selector:
app: hello-kubernetes
- name: Create Route with fewest possible arguments
community.okd.openshift_route:
service: hello-kubernetes
namespace: default
register: route
- name: Attempt to hit http URL
uri:
url: 'http://{{ route.result.spec.host }}'
return_content: yes
until: result is successful
retries: 10
register: result
- name: Assert the page content is as expected
assert:
that:
- not result.redirected
- result.status == 200
- result.content == 'Hello OpenShift!\n'
- name: Delete route
community.okd.openshift_route:
name: '{{ route.result.metadata.name }}'
namespace: default
state: absent
wait: yes
- name: Create Route with custom name and wait
community.okd.openshift_route:
service: hello-kubernetes
namespace: default
name: test1
wait: yes
register: route
- name: Assert that the condition is properly set
assert:
that:
- route.duration is defined
- route.result.status.ingress.0.conditions.0.type == 'Admitted'
- route.result.status.ingress.0.conditions.0.status == 'True'
- name: Attempt to hit http URL
uri:
url: 'http://{{ route.result.spec.host }}'
return_content: yes
register: result
- name: Assert the page content is as expected
assert:
that:
- not result.redirected
- result.status == 200
- result.content == 'Hello OpenShift!\n'
- name: Delete route
community.okd.openshift_route:
name: '{{ route.result.metadata.name }}'
namespace: default
state: absent
wait: yes
- name: Create edge-terminated route that allows insecure traffic
community.okd.openshift_route:
service: hello-kubernetes
namespace: default
name: hello-kubernetes-https
tls:
insecure_policy: allow
termination: edge
register: route
- name: Attempt to hit http URL
uri:
url: 'http://{{ route.result.spec.host }}'
return_content: yes
until: result is successful
retries: 10
register: result
- name: Assert the page content is as expected
assert:
that:
- not result.redirected
- result.status == 200
- result.content == 'Hello OpenShift!\n'
- name: Attempt to hit https URL
uri:
url: 'https://{{ route.result.spec.host }}'
validate_certs: no
return_content: yes
until: result is successful
retries: 10
register: result
- name: Assert the page content is as expected
assert:
that:
- not result.redirected
- result.status == 200
- result.content == 'Hello OpenShift!\n'
- name: Alter edge-terminated route to redirect insecure traffic
community.okd.openshift_route:
service: hello-kubernetes
namespace: default
name: hello-kubernetes-https
tls:
insecure_policy: redirect
termination: edge
register: route
- name: Attempt to hit http URL
uri:
url: 'http://{{ route.result.spec.host }}'
return_content: yes
validate_certs: no
until:
- result is successful
- result.redirected
retries: 10
register: result
- name: Assert the page content is as expected
assert:
that:
- result.redirected
- result.status == 200
- result.content == 'Hello OpenShift!\n'
- name: Attempt to hit https URL
uri:
url: 'https://{{ route.result.spec.host }}'
validate_certs: no
return_content: yes
until: result is successful
retries: 10
register: result
- name: Assert the page content is as expected
assert:
that:
- not result.redirected
- result.status == 200
- result.content == 'Hello OpenShift!\n'
- name: Alter edge-terminated route with insecure traffic disabled
community.okd.openshift_route:
service: hello-kubernetes
namespace: default
name: hello-kubernetes-https
tls:
insecure_policy: disallow
termination: edge
register: route
- debug: var=route
- name: Attempt to hit https URL
uri:
url: 'https://{{ route.result.spec.host }}'
validate_certs: no
return_content: yes
until: result is successful
retries: 10
register: result
- name: Assert the page content is as expected
assert:
that:
- not result.redirected
- result.status == 200
- result.content == 'Hello OpenShift!\n'
- name: Attempt to hit http URL
uri:
url: 'http://{{ route.result.spec.host }}'
status_code: 503
until: result is successful
retries: 10
register: result
- debug: var=result
- name: Assert the page content is as expected
assert:
that:
- not result.redirected
- result.status == 503
- name: Delete route
community.okd.openshift_route:
name: '{{ route.result.metadata.name }}'
namespace: default
state: absent
wait: yes

View File

@@ -61,3 +61,4 @@
- import_tasks: tasks/validate_not_installed.yml
- import_tasks: tasks/openshift_auth.yml
- import_tasks: tasks/openshift_route.yml

View File

@@ -0,0 +1,544 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Red Hat
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
module: openshift_route
short_description: Expose a Service as an OpenShift Route.
version_added: "1.1.0"
author: "Fabian von Feilitzsch (@fabianvf)"
description:
- Looks up a Service and creates a new Route based on it.
- Analogous to `oc expose` and `oc create route` for creating Routes, but does not support creating Services.
- For creating Services from other resources, see community.kubernetes.k8s_expose
extends_documentation_fragment:
- community.kubernetes.k8s_auth_options
- community.kubernetes.k8s_wait_options
- community.kubernetes.k8s_state_options
requirements:
- "python >= 2.7"
- "openshift >= 0.11.0"
- "PyYAML >= 3.11"
options:
service:
description:
- The name of the service to expose.
- Required when I(state) is not absent.
type: str
aliases: ['svc']
namespace:
description:
- The namespace of the resource being targeted.
- The Route will be created in this namespace as well.
required: yes
type: str
labels:
description:
- Specify the labels to apply to the created Route.
- 'A set of key: value pairs.'
type: dict
name:
description:
- The desired name of the Route to be created.
- Defaults to the value of I(service)
type: str
hostname:
description:
- The hostname for the Route.
type: str
path:
description:
- The path for the Route
type: str
wildcard_policy:
description:
- The wildcard policy for the hostname.
- Currently only Subdomain is supported.
- If not provided, the default of None will be used.
choices:
- Subdomain
type: str
port:
description:
- Name or number of the port the Route will route traffic to.
type: str
tls:
description:
- TLS configuration for the newly created route.
- Only used when I(termination) is set.
type: dict
suboptions:
ca_certificate:
description:
- Path to a CA certificate file on the target host.
- Not supported when I(termination) is set to passthrough.
type: str
certificate:
description:
- Path to a certificate file on the target host.
- Not supported when I(termination) is set to passthrough.
type: str
destination_ca_certificate:
description:
- Path to a CA certificate file used for securing the connection.
- Only used when I(termination) is set to reencrypt.
- Defaults to the Service CA.
type: str
key:
description:
- Path to a key file on the target host.
- Not supported when I(termination) is set to passthrough.
type: str
insecure_policy:
description:
- Sets the InsecureEdgeTerminationPolicy for the Route.
- Not supported when I(termination) is set to reencrypt.
- When I(termination) is set to passthrough, only redirect is supported.
- If not provided, insecure traffic will be disallowed.
type: str
choices:
- allow
- redirect
- disallow
default: disallow
termination:
description:
- The termination type of the Route.
- If left empty no termination type will be set, and the route will be insecure.
- When set to insecure I(tls) will be ignored.
choices:
- edge
- passthrough
- reencrypt
- insecure
default: insecure
type: str
'''
EXAMPLES = r'''
- name: Create hello-world deployment
community.okd.k8s:
definition:
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-kubernetes
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: hello-kubernetes
template:
metadata:
labels:
app: hello-kubernetes
spec:
containers:
- name: hello-kubernetes
image: paulbouwer/hello-kubernetes:1.8
ports:
- containerPort: 8080
- name: Create Service for the hello-world deployment
community.okd.k8s:
definition:
apiVersion: v1
kind: Service
metadata:
name: hello-kubernetes
namespace: default
spec:
ports:
- port: 80
targetPort: 8080
selector:
app: hello-kubernetes
- name: Expose the insecure hello-world service externally
community.okd.openshift_route:
service: hello-kubernetes
namespace: default
insecure_policy: allow
register: route
'''
RETURN = r'''
result:
description:
- The Route object that was created or updated. Will be empty in the case of deletion.
returned: success
type: complex
contains:
apiVersion:
description: The versioned schema of this representation of an object.
returned: success
type: str
kind:
description: Represents the REST resource this object represents.
returned: success
type: str
metadata:
description: Standard object metadata. Includes name, namespace, annotations, labels, etc.
returned: success
type: complex
contains:
name:
description: The name of the created Route
type: str
namespace:
description: The namespace of the create Route
type: str
spec:
description: Specification for the Route
returned: success
type: complex
contains:
host:
description: Host is an alias/DNS that points to the service.
type: str
path:
description: Path that the router watches for, to route traffic for to the service.
type: str
port:
description: Defines a port mapping from a router to an endpoint in the service endpoints.
type: complex
contains:
targetPort:
description: The target port on pods selected by the service this route points to.
type: str
tls:
description: Defines config used to secure a route and provide termination.
type: complex
contains:
caCertificate:
description: Provides the cert authority certificate contents.
type: str
certificate:
description: Provides certificate contents.
type: str
destinationCACertificate:
description: Provides the contents of the ca certificate of the final destination.
type: str
insecureEdgeTerminationPolicy:
description: Indicates the desired behavior for insecure connections to a route.
type: str
key:
description: Provides key file contents.
type: str
termination:
description: Indicates termination type.
type: str
to:
description: Specifies the target that resolve into endpoints.
type: complex
contains:
kind:
description: The kind of target that the route is referring to. Currently, only 'Service' is allowed.
type: str
name:
description: Name of the service/target that is being referred to. e.g. name of the service.
type: str
weight:
description: Specifies the target's relative weight against other target reference objects.
type: int
wildcardPolicy:
description: Wildcard policy if any for the route.
type: str
status:
description: Current status details for the Route
returned: success
type: complex
contains:
ingress:
description: List of places where the route may be exposed.
type: complex
contains:
conditions:
description: Array of status conditions for the Route ingress.
type: complex
contains:
type:
description: The type of the condition. Currently only 'Ready'.
type: str
status:
description: The status of the condition. Can be True, False, Unknown.
type: str
host:
description: The host string under which the route is exposed.
type: str
routerCanonicalHostname:
description: The external host name for the router that can be used as a CNAME for the host requested for this route. May not be set.
type: str
routerName:
description: A name chosen by the router to identify itself.
type: str
wildcardPolicy:
description: The wildcard policy that was allowed where this route is exposed.
type: str
duration:
description: elapsed time of task in seconds
returned: when C(wait) is true
type: int
sample: 48
'''
import copy
import traceback
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native
try:
from ansible_collections.community.kubernetes.plugins.module_utils.common import (
K8sAnsibleMixin, AUTH_ARG_SPEC, WAIT_ARG_SPEC, COMMON_ARG_SPEC
)
HAS_KUBERNETES_COLLECTION = True
except ImportError as e:
HAS_KUBERNETES_COLLECTION = False
k8s_collection_import_exception = e
K8S_COLLECTION_ERROR = traceback.format_exc()
K8sAnsibleMixin = object
AUTH_ARG_SPEC = WAIT_ARG_SPEC = COMMON_ARG_SPEC = {}
try:
from openshift.dynamic.exceptions import DynamicApiError, NotFoundError
except ImportError:
pass
class OpenShiftRoute(K8sAnsibleMixin):
def __init__(self):
self.module = AnsibleModule(
argument_spec=self.argspec,
supports_check_mode=True,
)
self.fail_json = self.module.fail_json
if not HAS_KUBERNETES_COLLECTION:
self.module.fail_json(
msg="The community.kubernetes collection must be installed",
exception=K8S_COLLECTION_ERROR,
error=to_native(k8s_collection_import_exception)
)
super(OpenShiftRoute, self).__init__()
self.params = self.module.params
# TODO: should probably make it so that at least some of these aren't required for perform_action to work
# Or at least explicitly pass them in
self.append_hash = False
self.apply = False
self.check_mode = self.module.check_mode
self.warnings = []
self.params['merge_type'] = None
@property
def argspec(self):
spec = copy.deepcopy(AUTH_ARG_SPEC)
spec.update(copy.deepcopy(WAIT_ARG_SPEC))
spec.update(copy.deepcopy(COMMON_ARG_SPEC))
spec['service'] = dict(type='str', aliases=['svc'])
spec['namespace'] = dict(required=True, type='str')
spec['labels'] = dict(type='dict')
spec['name'] = dict(type='str')
spec['hostname'] = dict(type='str')
spec['path'] = dict(type='str')
spec['wildcard_policy'] = dict(choices=['Subdomain'], type='str')
spec['port'] = dict(type='str')
spec['tls'] = dict(type='dict', options=dict(
ca_certificate=dict(type='str'),
certificate=dict(type='str'),
destination_ca_certificate=dict(type='str'),
key=dict(type='str'),
insecure_policy=dict(type='str', choices=['allow', 'redirect', 'disallow'], default='disallow'),
))
spec['termination'] = dict(choices=['edge', 'passthrough', 'reencrypt', 'insecure'], default='insecure')
return spec
def execute_module(self):
self.client = self.get_api_client()
v1_routes = self.find_resource('Route', 'route.openshift.io/v1', fail=True)
service_name = self.params.get('service')
namespace = self.params['namespace']
termination_type = self.params.get('termination')
if termination_type == 'insecure':
termination_type = None
state = self.params.get('state')
if state != 'absent' and not service_name:
self.fail_json("If 'state' is not 'absent' then 'service' must be provided")
# We need to do something a little wonky to wait if the user doesn't supply a custom condition
custom_wait = self.params.get('wait') and not self.params.get('wait_condition') and state != 'absent'
if custom_wait:
# Don't use default wait logic in perform_action
self.params['wait'] = False
route_name = self.params.get('name') or service_name
labels = self.params.get('labels')
hostname = self.params.get('hostname')
path = self.params.get('path')
wildcard_policy = self.params.get('wildcard_policy')
port = self.params.get('port')
if termination_type and self.params.get('tls'):
tls_ca_cert = self.params['tls'].get('ca_certificate')
tls_cert = self.params['tls'].get('certificate')
tls_dest_ca_cert = self.params['tls'].get('destination_ca_certificate')
tls_key = self.params['tls'].get('key')
tls_insecure_policy = self.params['tls'].get('insecure_policy')
if tls_insecure_policy == 'disallow':
tls_insecure_policy = None
else:
tls_ca_cert = tls_cert = tls_dest_ca_cert = tls_key = tls_insecure_policy = None
route = {
'apiVersion': 'route.openshift.io/v1',
'kind': 'Route',
'metadata': {
'name': route_name,
'namespace': namespace,
'labels': labels,
},
'spec': {}
}
if state != 'absent':
route['spec'] = self.build_route_spec(
service_name, namespace,
port=port,
wildcard_policy=wildcard_policy,
hostname=hostname,
path=path,
termination_type=termination_type,
tls_insecure_policy=tls_insecure_policy,
tls_ca_cert=tls_ca_cert,
tls_cert=tls_cert,
tls_key=tls_key,
tls_dest_ca_cert=tls_dest_ca_cert,
)
result = self.perform_action(v1_routes, route)
timeout = self.params.get('wait_timeout')
sleep = self.params.get('wait_sleep')
if custom_wait:
success, result['result'], result['duration'] = self._wait_for(v1_routes, route_name, namespace, wait_predicate, sleep, timeout, state)
self.module.exit_json(**result)
def build_route_spec(self, service_name, namespace, port=None, wildcard_policy=None, hostname=None, path=None, termination_type=None,
tls_insecure_policy=None, tls_ca_cert=None, tls_cert=None, tls_key=None, tls_dest_ca_cert=None):
v1_services = self.find_resource('Service', 'v1', fail=True)
try:
target_service = v1_services.get(name=service_name, namespace=namespace)
except NotFoundError:
if not port:
self.module.fail_json(msg="You need to provide the 'port' argument when exposing a non-existent service")
target_service = None
except DynamicApiError as exc:
self.module.fail_json(msg='Failed to retrieve service to be exposed: {0}'.format(exc.body),
error=exc.status, status=exc.status, reason=exc.reason)
except Exception as exc:
self.module.fail_json(msg='Failed to retrieve service to be exposed: {0}'.format(to_native(exc)),
error='', status='', reason='')
route_spec = {
'tls': {},
'to': {
'kind': 'Service',
'name': service_name,
},
'port': {
'targetPort': self.set_port(target_service, port),
},
'wildcardPolicy': wildcard_policy
}
# Want to conditionally add these so we don't overwrite what is automically added when nothing is provided
if termination_type:
route_spec['tls'] = dict(termination=termination_type.capitalize())
if tls_insecure_policy:
if termination_type == 'edge':
route_spec['tls']['insecureEdgeTerminationPolicy'] = tls_insecure_policy.capitalize()
elif termination_type == 'passthrough':
if tls_insecure_policy != 'redirect':
self.module.fail_json("'redirect' is the only supported insecureEdgeTerminationPolicy for passthrough routes")
route_spec['tls']['insecureEdgeTerminationPolicy'] = tls_insecure_policy.capitalize()
elif termination_type == 'reencrypt':
self.module.fail_json("'tls.insecure_policy' is not supported with reencrypt routes")
else:
route_spec['tls']['insecureEdgeTerminationPolicy'] = None
if tls_ca_cert:
if termination_type == 'passthrough':
self.module.fail_json("'tls.ca_certificate' is not supported with passthrough routes")
route_spec['tls']['caCertificate'] = tls_ca_cert
if tls_cert:
if termination_type == 'passthrough':
self.module.fail_json("'tls.certificate' is not supported with passthrough routes")
route_spec['tls']['certificate'] = tls_cert
if tls_key:
if termination_type == 'passthrough':
self.module.fail_json("'tls.key' is not supported with passthrough routes")
route_spec['tls']['key'] = tls_key
if tls_dest_ca_cert:
if termination_type != 'reencrypt':
self.module.fail_json("'destination_certificate' is only valid for reencrypt routes")
route_spec['tls']['destinationCACertificate'] = tls_dest_ca_cert
else:
route_spec['tls'] = None
if hostname:
route_spec['host'] = hostname
if path:
route_spec['path'] = path
return route_spec
def set_port(self, service, port_arg):
if port_arg:
return port_arg
for p in service.spec.ports:
if p.protocol == 'TCP':
if p.name is not None:
return p.name
return p.targetPort
return None
def wait_predicate(route):
if not(route.status and route.status.ingress):
return False
for ingress in route.status.ingress:
match = [x for x in ingress.conditions if x.type == 'Admitted']
if not match:
return False
match = match[0]
if match.status != "True":
return False
return True
def main():
OpenShiftRoute().execute_module()
if __name__ == '__main__':
main()