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