github.com/joelanford/operator-sdk@v0.8.2/internal/pkg/scaffold/ansible/k8s_status.go (about)

     1  // Copyright 2018 The Operator-SDK Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package ansible
    16  
    17  import (
    18  	"github.com/operator-framework/operator-sdk/internal/pkg/scaffold/input"
    19  )
    20  
    21  const K8sStatusPythonFile = "library/k8s_status.py"
    22  
    23  // K8sStatus - the k8s status module tmpl wrapper
    24  type K8sStatus struct {
    25  	StaticInput
    26  }
    27  
    28  // GetInput - gets the input
    29  func (k *K8sStatus) GetInput() (input.Input, error) {
    30  	if k.Path == "" {
    31  		k.Path = K8sStatusPythonFile
    32  	}
    33  
    34  	k.TemplateBody = k8sStatusTmpl
    35  
    36  	return k.Input, nil
    37  }
    38  
    39  const k8sStatusTmpl = `#!/usr/bin/python
    40  # -*- coding: utf-8 -*-
    41  
    42  from __future__ import absolute_import, division, print_function
    43  
    44  import re
    45  import copy
    46  
    47  from ansible.module_utils.k8s.common import AUTH_ARG_SPEC, COMMON_ARG_SPEC, KubernetesAnsibleModule
    48  
    49  try:
    50      from openshift.dynamic.exceptions import DynamicApiError
    51  except ImportError as exc:
    52      class KubernetesException(Exception):
    53          pass
    54  
    55  
    56  __metaclass__ = type
    57  
    58  ANSIBLE_METADATA = {'metadata_version': '1.1',
    59                      'status': ['preview'],
    60                      'supported_by': 'community'}
    61  
    62  DOCUMENTATION = '''
    63  
    64  module: k8s_status
    65  
    66  short_description: Update the status for a Kubernetes API resource
    67  
    68  version_added: "2.7"
    69  
    70  author: "Fabian von Feilitzsch (@fabianvf)"
    71  
    72  description:
    73    - Sets the status field on a Kubernetes API resource. Only should be used if you are using Ansible to
    74      implement a controller for the resource being modified.
    75  
    76  options:
    77    status:
    78      type: dict
    79      description:
    80      - A object containing ` + "`key: value`" + ` pairs that will be set on the status object of the specified resource.
    81      - One of I(status) or I(conditions) is required.
    82    conditions:
    83      type: list
    84      description:
    85      - A list of condition objects that will be set on the status.conditions field of the specified resource.
    86      - Unless I(force) is C(true) the specified conditions will be merged with the conditions already set on the status field of the specified resource.
    87      - Each element in the list will be validated according to the conventions specified in the
    88        [Kubernetes API conventions document](https://github.com/kubernetes/community/blob/master/contributors/devel/api-conventions.md#spec-and-status).
    89      - 'The fields supported for each condition are:
    90        ` + "`type`" + ` (required),
    91        ` + "`status`" + ` (required, one of "True", "False", "Unknown"),
    92        ` + "`reason`" + ` (single CamelCase word),
    93        ` + "`message`" + `,
    94        ` + "`lastHeartbeatTime`" + ` (RFC3339 datetime string), and
    95        ` + "`lastTransitionTime`" + ` (RFC3339 datetime string).'
    96      - One of I(status) or I(conditions) is required.'
    97    api_version:
    98      description:
    99      - Use to specify the API version. Use in conjunction with I(kind), I(name), and I(namespace) to identify a
   100        specific object.
   101      required: yes
   102      aliases:
   103      - api
   104      - version
   105    kind:
   106      description:
   107      - Use to specify an object model. Use in conjunction with I(api_version), I(name), and I(namespace) to identify a
   108        specific object.
   109      required: yes
   110    name:
   111      description:
   112      - Use to specify an object name. Use in conjunction with I(api_version), I(kind) and I(namespace) to identify a
   113        specific object.
   114      required: yes
   115    namespace:
   116      description:
   117      - Use to specify an object namespace. Use in conjunction with I(api_version), I(kind), and I(name)
   118        to identify a specific object.
   119    force:
   120      description:
   121      - If set to C(True), the status will be set using ` + "`PUT`" + ` rather than ` + "`PATCH`" + `, replacing the full status object.
   122      default: false
   123      type: bool
   124    host:
   125      description:
   126      - Provide a URL for accessing the API. Can also be specified via K8S_AUTH_HOST environment variable.
   127    api_key:
   128      description:
   129      - Token used to authenticate with the API. Can also be specified via K8S_AUTH_API_KEY environment variable.
   130    kubeconfig:
   131      description:
   132      - Path to an instance Kubernetes config file. If not provided, and no other connection
   133        options are provided, the openshift client will attempt to load the default
   134        configuration file from I(~/.kube/config.json). Can also be specified via K8S_AUTH_KUBECONFIG environment
   135        variable.
   136    context:
   137      description:
   138      - The name of a context found in the config file. Can also be specified via K8S_AUTH_CONTEXT environment variable.
   139    username:
   140      description:
   141      - Provide a username for authenticating with the API. Can also be specified via K8S_AUTH_USERNAME environment
   142        variable.
   143    password:
   144      description:
   145      - Provide a password for authenticating with the API. Can also be specified via K8S_AUTH_PASSWORD environment
   146        variable.
   147    cert_file:
   148      description:
   149      - Path to a certificate used to authenticate with the API. Can also be specified via K8S_AUTH_CERT_FILE environment
   150        variable.
   151    key_file:
   152      description:
   153      - Path to a key file used to authenticate with the API. Can also be specified via K8S_AUTH_KEY_FILE environment
   154        variable.
   155    ssl_ca_cert:
   156      description:
   157      - Path to a CA certificate used to authenticate with the API. Can also be specified via K8S_AUTH_SSL_CA_CERT
   158        environment variable.
   159    verify_ssl:
   160      description:
   161      - "Whether or not to verify the API server's SSL certificates. Can also be specified via K8S_AUTH_VERIFY_SSL
   162        environment variable."
   163      type: bool
   164  
   165  requirements:
   166      - "python >= 2.7"
   167      - "openshift >= 0.8.1"
   168      - "PyYAML >= 3.11"
   169  '''
   170  
   171  EXAMPLES = '''
   172  - name: Set custom status fields on TestCR
   173    k8s_status:
   174      api_version: apps.example.com/v1alpha1
   175      kind: TestCR
   176      name: my-test
   177      namespace: testing
   178      status:
   179          hello: world
   180          custom: entries
   181  
   182  - name: Update the standard condition of an Ansible Operator
   183    k8s_status:
   184      api_version: apps.example.com/v1alpha1
   185      kind: TestCR
   186      name: my-test
   187      namespace: testing
   188      conditions:
   189      - type: Running
   190        status: "True"
   191        reason: MigrationStarted
   192        message: "Migration from v2 to v3 has begun"
   193        lastTransitionTime: "{{ ansible_date_time.iso8601 }}"
   194  
   195  - name: |
   196      Create custom conditions. WARNING: The default Ansible Operator status management
   197      will never overwrite custom conditions, so they will persist indefinitely. If you
   198      want the values to change or be removed, you will need to clean them up manually.
   199    k8s_status:
   200      conditions:
   201      - type: Available
   202        status: "False"
   203        reason: PingFailed
   204        message: "The service did not respond to a ping"
   205  
   206  '''
   207  
   208  RETURN = '''
   209  result:
   210    description:
   211    - If a change was made, will return the patched object, otherwise returns the instance object.
   212    returned: success
   213    type: complex
   214    contains:
   215       api_version:
   216         description: The versioned schema of this representation of an object.
   217         returned: success
   218         type: str
   219       kind:
   220         description: Represents the REST resource this object represents.
   221         returned: success
   222         type: str
   223       metadata:
   224         description: Standard object metadata. Includes name, namespace, annotations, labels, etc.
   225         returned: success
   226         type: complex
   227       spec:
   228         description: Specific attributes of the object. Will vary based on the I(api_version) and I(kind).
   229         returned: success
   230         type: complex
   231       status:
   232         description: Current status details for the object.
   233         returned: success
   234         type: complex
   235  '''
   236  
   237  
   238  def condition_array(conditions):
   239  
   240      VALID_KEYS = ['type', 'status', 'reason', 'message', 'lastHeartbeatTime', 'lastTransitionTime']
   241      REQUIRED = ['type', 'status']
   242      CAMEL_CASE = re.compile(r'^(?:[A-Z]*[a-z]*)+$')
   243      RFC3339_datetime = re.compile(r'^\d{4}-\d\d-\d\dT\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)$')
   244  
   245      def validate_condition(condition):
   246          if not isinstance(condition, dict):
   247              raise ValueError('` + "`conditions`" + ` must be a list of objects')
   248          if isinstance(condition.get('status'), bool):
   249              condition['status'] = 'True' if condition['status'] else 'False'
   250  
   251          for key in condition.keys():
   252              if key not in VALID_KEYS:
   253                  raise ValueError('{} is not a valid field for a condition, accepted fields are {}'.format(key, VALID_KEYS))
   254          for key in REQUIRED:
   255              if not condition.get(key):
   256                  raise ValueError('Condition ` + "`{}`" + ` must be set'.format(key))
   257  
   258          if condition['status'] not in ['True', 'False', 'Unknown']:
   259              raise ValueError('Condition ` + "`status`" + ` must be one of ["True", "False", "Unknown"], not {}'.format(condition['status']))
   260  
   261          if condition.get('reason') and not re.match(CAMEL_CASE, condition['reason']):
   262              raise ValueError('Condition ` + "`reason`" + ` must be a single, CamelCase word')
   263  
   264          for key in ['lastHeartBeatTime', 'lastTransitionTime']:
   265              if condition.get(key) and not re.match(RFC3339_datetime, condition[key]):
   266                  raise ValueError('` + "`{}`" + ` must be a RFC3339 compliant datetime string'.format(key))
   267  
   268          return condition
   269  
   270      return [validate_condition(c) for c in conditions]
   271  
   272  
   273  STATUS_ARG_SPEC = {
   274      'status': {
   275          'type': 'dict',
   276          'required': False
   277      },
   278      'conditions': {
   279          'type': condition_array,
   280          'required': False
   281      }
   282  }
   283  
   284  
   285  def main():
   286      KubernetesAnsibleStatusModule().execute_module()
   287  
   288  
   289  class KubernetesAnsibleStatusModule(KubernetesAnsibleModule):
   290  
   291      def __init__(self, *args, **kwargs):
   292          KubernetesAnsibleModule.__init__(
   293              self, *args,
   294              supports_check_mode=True,
   295              **kwargs
   296          )
   297          self.kind = self.params.get('kind')
   298          self.api_version = self.params.get('api_version')
   299          self.name = self.params.get('name')
   300          self.namespace = self.params.get('namespace')
   301          self.force = self.params.get('force')
   302  
   303          self.status = self.params.get('status') or {}
   304          self.conditions = self.params.get('conditions') or []
   305  
   306          if self.conditions and self.status and self.status.get('conditions'):
   307              raise ValueError("You cannot specify conditions in both the ` + "`status`" + ` and ` + "`conditions`" + ` parameters")
   308  
   309          if self.conditions:
   310              self.status['conditions'] = self.conditions
   311  
   312      def execute_module(self):
   313          self.client = self.get_api_client()
   314  
   315          resource = self.find_resource(self.kind, self.api_version, fail=True)
   316          if 'status' not in resource.subresources:
   317              self.fail_json(msg='Resource {}.{} does not support the status subresource'.format(resource.api_version, resource.kind))
   318  
   319          try:
   320              instance = resource.get(name=self.name, namespace=self.namespace).to_dict()
   321          except DynamicApiError as exc:
   322              self.fail_json(msg='Failed to retrieve requested object: {0}'.format(exc),
   323                             error=exc.summary())
   324          # Make sure status is at least initialized to an empty dict
   325          instance['status'] = instance.get('status', {})
   326  
   327          if self.force:
   328              self.exit_json(**self.replace(resource, instance))
   329          else:
   330              self.exit_json(**self.patch(resource, instance))
   331  
   332      def replace(self, resource, instance):
   333          if self.status == instance['status']:
   334              return {'result': instance, 'changed': False}
   335          instance['status'] = self.status
   336          try:
   337              result = resource.status.replace(body=instance).to_dict(),
   338          except DynamicApiError as exc:
   339              self.fail_json(msg='Failed to replace status: {}'.format(exc), error=exc.summary())
   340  
   341          return {
   342              'result': result,
   343              'changed': True
   344          }
   345  
   346      def patch(self, resource, instance):
   347          if self.object_contains(instance['status'], self.status):
   348              return {'result': instance, 'changed': False}
   349          instance['status'] = self.merge_status(instance['status'], self.status)
   350          try:
   351              result = resource.status.patch(body=instance, content_type='application/merge-patch+json').to_dict()
   352          except DynamicApiError as exc:
   353              self.fail_json(msg='Failed to replace status: {}'.format(exc), error=exc.summary())
   354  
   355          return {
   356              'result': result,
   357              'changed': True
   358          }
   359  
   360      def merge_status(self, old, new):
   361          old_conditions = old.get('conditions', [])
   362          new_conditions = new.get('conditions', [])
   363          if not (old_conditions and new_conditions):
   364              return new
   365  
   366          merged = copy.deepcopy(old_conditions)
   367  
   368          for condition in new_conditions:
   369              idx = self.get_condition_idx(merged, condition['type'])
   370              if idx:
   371                  merged[idx] = condition
   372              else:
   373                  merged.append(condition)
   374          new['conditions'] = merged
   375          return new
   376  
   377      def get_condition_idx(self, conditions, name):
   378          for i, condition in enumerate(conditions):
   379              if condition.get('type') == name:
   380                  return i
   381  
   382      def object_contains(self, obj, subset):
   383          def dict_is_subset(obj, subset):
   384              return all([mapping.get(type(obj.get(k)), mapping['default'])(obj.get(k), v) for (k, v) in subset.items()])
   385  
   386          def list_is_subset(obj, subset):
   387              return all(item in obj for item in subset)
   388  
   389          def values_match(obj, subset):
   390              return obj == subset
   391  
   392          mapping = {
   393              dict: dict_is_subset,
   394              list: list_is_subset,
   395              tuple: list_is_subset,
   396              'default': values_match
   397          }
   398  
   399          return dict_is_subset(obj, subset)
   400  
   401      @property
   402      def argspec(self):
   403          args = copy.deepcopy(COMMON_ARG_SPEC)
   404          args.pop('state')
   405          args.pop('resource_definition')
   406          args.pop('src')
   407          args.update(AUTH_ARG_SPEC)
   408          args.update(STATUS_ARG_SPEC)
   409          return args
   410  
   411  
   412  if __name__ == '__main__':
   413      main()
   414  `