Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Admission webhooks for validation & mutation #708

Merged
merged 10 commits into from
Mar 27, 2021
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ the same packages, the same developer(s).
* Dynamically generated or conditional sub-handlers (an advanced feature).
* Timers that tick as long as the resource exists, optionally with a delay since the last change.
* Daemons that run as long as the resource exists (in threads or asyncio-tasks).
* Validating and mutating admission webhook (with dev-mode tunneling).
* Live in-memory indexing of resources or their excerpts.
* Filtering with stealth mode (no logging): by arbitrary filtering functions,
by labels/annotations with values, presence/absence, or dynamic callbacks.
* In-memory all-purpose containers to store non-serializable objects for individual resources.
Expand Down
664 changes: 664 additions & 0 deletions docs/admission.rst

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions docs/deployment-rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ rules:
resources: [namespaces]
verbs: [list, watch]

# Framework: admission webhook configuration management.
- apiGroups: [admissionregistration.k8s.io/v1, admissionregistration.k8s.io/v1beta1]
resources: [validatingwebhookconfigurations, mutatingwebhookconfigurations]
verbs: [create, patch]

# Application: read-only access for watching cluster-wide.
- apiGroups: [kopf.dev]
resources: [kopfexamples]
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Kopf: Kubernetes Operators Framework
scopes
memos
indexing
admission

.. toctree::
:maxdepth: 2
Expand Down
72 changes: 72 additions & 0 deletions docs/kwargs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,75 @@ Its ``.wait()`` method can be used to replace ``time.sleep()``
or ``asyncio.sleep()`` for faster (instant) termination on resource deletion.

See more: :doc:`daemons`.


Resource admission kwargs
=========================

.. kwarg:: dryrun

Dry run
-------

Admission handlers, both validating and mutating, must skip any side effects
if ``dryrun`` is ``True``. It is ``True`` when a dry-run API request is made,
e.g. with ``kubectl --dry-run=server ...``.

Regardless of ``dryrun`, the handlers must not make any side effects
unless they declare themselves as ``side_effects=True``.

See more: :doc:`admission`.


.. kwarg:: warnings

Admission warnings
------------------

``warnings`` (``list[str]``) is a **mutable** list of string used as warnings.
The admission webhook handlers can populate the list with warnings (strings),
and the webhook servers/tunnels return them to Kubernetes, which shows them
to ``kubectl``.

See more: :doc:`admission`.


.. kwarg:: userinfo

User information
----------------

``userinfo`` (``Mapping[str, Any]``) is an information about a user that
sends the API request to Kubernetes.

It usually contains the keys ``'username'``, ``'uid'``, ``'groups'``,
but this might change in the future. The information is provided exactly
as Kubernetes sends it in the admission request.

See more: :doc:`admission`.


.. kwarg:: headers
.. kwarg:: sslpeer

Request credentials
-------------------

For rudimentary authentication and authorization, Kopf passes the information
from the admission requests to the admission handlers as is,
without additional interpretation of it.

``headers`` (``Mapping[str, str]``) contains all HTTPS request headers,
including ``Authorization: Basic ...``, ``Authorization: Bearer ...``.

``sslpeer`` (``Mapping[str, Any]``) contains the SSL peer information
as returned by `ssl.SSLSocket.getpeercert`. It is ``None`` if no proper
SSL client certificate was provided (i.e. by apiservers talking to webhooks),
or if the SSL protocol could not verify the provided certificate with its CA.

.. note::
This is an identity of the apiservers that send the admission request,
not of the user or an app that sends the API request to Kubernetes.
For the user's identity, use :kwarg:`userinfo`.

See more: :doc:`admission`.
59 changes: 59 additions & 0 deletions examples/17-admission/example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import pathlib
from typing import Dict

import kopf


@kopf.on.startup()
def config(settings: kopf.OperatorSettings, **_):
ROOT = (pathlib.Path.cwd() / pathlib.Path(__file__)).parent.parent.parent
settings.admission.managed = 'auto.kopf.dev'
settings.admission.server = kopf.WebhookK3dServer(cadump=ROOT/'ca.pem')
## Other options (see the docs):
# settings.admission.server = kopf.WebhookServer()
# settings.admission.server = kopf.WebhookServer(certfile=ROOT/'cert.pem', pkeyfile=ROOT/'key.pem', port=1234)
# settings.admission.server = kopf.WebhookK3dServer(cadump=ROOT/'ca.pem')
# settings.admission.server = kopf.WebhookK3dServer(certfile=ROOT/'k3d-cert.pem', pkeyfile=ROOT/'k3d-key.pem', port=1234)
# settings.admission.server = kopf.WebhookMinikubeServer(port=1234, cadump=ROOT/'ca.pem', verify_cafile=ROOT/'client-cert.pem')
# settings.admission.server = kopf.WebhookNgrokTunnel()
# settings.admission.server = kopf.WebhookNgrokTunnel(binary="/usr/local/bin/ngrok", token='...', port=1234)
# settings.admission.server = kopf.WebhookNgrokTunnel(binary="/usr/local/bin/ngrok", port=1234, path='/xyz', region='eu')


@kopf.on.validate('kex')
def authhook(headers, sslpeer, warnings, **_):
# print(f'headers={headers}')
# print(f'sslpeer={sslpeer}')
if not sslpeer:
warnings.append("SSL peer is not identified.")
else:
common_name = None
for key, val in sslpeer['subject'][0]:
if key == 'commonName':
common_name = val
break
else:
warnings.append("SSL peer's common name is absent.")
if common_name is not None:
warnings.append(f"SSL peer is {common_name}.")


@kopf.on.validate('kex')
def validate1(spec, dryrun, **_):
if not dryrun and spec.get('field') == 'wrong':
raise kopf.AdmissionError("Meh! I don't like it. Change the field.")


@kopf.on.validate('kex', field='spec.field', value='not-allowed')
def validate2(**_):
raise kopf.AdmissionError("I'm too lazy anyway. Go away!", code=555)


@kopf.on.mutate('kex', labels={'somelabel': 'somevalue'})
def mutate1(patch: kopf.Patch, **_):
patch.spec['injected'] = 123


# Marks for the e2e tests (see tests/e2e/test_examples.py):
# We do not care: pods can have 6-10 updates here.
E2E_SUCCESS_COUNTS = {} # type: Dict[str, int]
2 changes: 1 addition & 1 deletion examples/99-all-at-once/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
E2E_CREATION_STOP_WORDS = ['Creation is processed:']
E2E_DELETION_STOP_WORDS = ['Deleted, really deleted']
E2E_SUCCESS_COUNTS = {'create_1': 1, 'create_2': 1, 'create_pod': 1, 'delete': 1, 'startup_fn_simple': 1, 'startup_fn_retried': 1, 'cleanup_fn': 1}
E2E_FAILURE_COUNTS: Dict[str, int] = {}
E2E_FAILURE_COUNTS = {} # type: Dict[str, int]
E2E_TRACEBACKS = True


Expand Down
32 changes: 32 additions & 0 deletions kopf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
from kopf.reactor import (
lifecycles, # as a separate name on the public namespace
)
from kopf.reactor.admission import (
AdmissionError,
)
from kopf.reactor.handling import (
TemporaryError,
PermanentError,
Expand Down Expand Up @@ -132,6 +135,16 @@
Resource,
EVERYTHING,
)
from kopf.structs.reviews import (
WebhookClientConfigService,
WebhookClientConfig,
Operation,
UserInfo,
Headers,
SSLPeer,
WebhookFn,
WebhookServerProtocol,
)
from kopf.toolkits.hierarchies import (
adopt,
label,
Expand All @@ -140,6 +153,12 @@
append_owner_reference,
remove_owner_reference,
)
from kopf.toolkits.webhooks import (
WebhookServer,
WebhookK3dServer,
WebhookMinikubeServer,
WebhookNgrokTunnel,
)
from kopf.utilities.piggybacking import (
login_via_pykube,
login_via_client,
Expand All @@ -160,6 +179,19 @@
'build_object_reference', 'build_owner_reference',
'append_owner_reference', 'remove_owner_reference',
'ErrorsMode',
'AdmissionError',
'WebhookClientConfigService',
'WebhookClientConfig',
'Operation',
'UserInfo',
'Headers',
'SSLPeer',
'WebhookFn',
'WebhookServerProtocol',
'WebhookServer',
'WebhookK3dServer',
'WebhookMinikubeServer',
'WebhookNgrokTunnel',
'PermanentError',
'TemporaryError',
'HandlerTimeoutError',
Expand Down
34 changes: 34 additions & 0 deletions kopf/clients/creating.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import Optional, cast

from kopf.clients import auth, errors
from kopf.structs import bodies, references


@auth.reauthenticated_request
async def create_obj(
*,
resource: references.Resource,
namespace: references.Namespace = None,
name: Optional[str] = None,
body: Optional[bodies.RawBody] = None,
context: Optional[auth.APIContext] = None, # injected by the decorator
) -> Optional[bodies.RawBody]:
"""
Create a resource.
"""
if context is None:
raise RuntimeError("API instance is not injected by the decorator.")

body = body if body is not None else {}
if namespace is not None:
body.setdefault('metadata', {}).setdefault('namespace', namespace)
if name is not None:
body.setdefault('metadata', {}).setdefault('name', name)

namespace = cast(references.Namespace, body.get('metadata', {}).get('namespace'))
response = await context.session.post(
url=resource.get_url(server=context.server, namespace=namespace),
json=body,
)
created_body: bodies.RawBody = await errors.parse_response(response)
return created_body
5 changes: 5 additions & 0 deletions kopf/clients/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ class APINotFoundError(APIError):
pass


class APIConflictError(APIError):
pass


async def check_response(
response: aiohttp.ClientResponse,
) -> None:
Expand All @@ -124,6 +128,7 @@ async def check_response(
APIUnauthorizedError if response.status == 401 else
APIForbiddenError if response.status == 403 else
APINotFoundError if response.status == 404 else
APIConflictError if response.status == 409 else
APIError
)

Expand Down
Loading