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 `