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 `