1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3 4# Copyright: (c) 2016, Hiroaki Nakamura <hnakamur@gmail.com> 5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 7from __future__ import absolute_import, division, print_function 8__metaclass__ = type 9 10 11DOCUMENTATION = ''' 12--- 13module: lxd_container 14short_description: Manage LXD Containers 15description: 16 - Management of LXD containers 17author: "Hiroaki Nakamura (@hnakamur)" 18options: 19 name: 20 description: 21 - Name of a container. 22 type: str 23 required: true 24 architecture: 25 description: 26 - 'The architecture for the container (for example C(x86_64) or C(i686)). 27 See U(https://github.com/lxc/lxd/blob/master/doc/rest-api.md#post-1).' 28 type: str 29 required: false 30 config: 31 description: 32 - 'The config for the container (for example C({"limits.cpu": "2"})). 33 See U(https://github.com/lxc/lxd/blob/master/doc/rest-api.md#post-1).' 34 - If the container already exists and its "config" values in metadata 35 obtained from GET /1.0/containers/<name> 36 U(https://github.com/lxc/lxd/blob/master/doc/rest-api.md#10containersname) 37 are different, this module tries to apply the configurations. 38 - The keys starting with C(volatile.) are ignored for this comparison when I(ignore_volatile_options=true). 39 type: dict 40 required: false 41 ignore_volatile_options: 42 description: 43 - If set to C(true), options starting with C(volatile.) are ignored. As a result, 44 they are reapplied for each execution. 45 - This default behavior can be changed by setting this option to C(false). 46 - The default value C(true) will be deprecated in community.general 4.0.0, 47 and will change to C(false) in community.general 5.0.0. 48 type: bool 49 default: true 50 required: false 51 version_added: 3.7.0 52 profiles: 53 description: 54 - Profile to be used by the container. 55 type: list 56 elements: str 57 devices: 58 description: 59 - 'The devices for the container 60 (for example C({ "rootfs": { "path": "/dev/kvm", "type": "unix-char" }})). 61 See U(https://github.com/lxc/lxd/blob/master/doc/rest-api.md#post-1).' 62 type: dict 63 required: false 64 ephemeral: 65 description: 66 - Whether or not the container is ephemeral (for example C(true) or C(false)). 67 See U(https://github.com/lxc/lxd/blob/master/doc/rest-api.md#post-1). 68 required: false 69 type: bool 70 source: 71 description: 72 - 'The source for the container 73 (e.g. { "type": "image", 74 "mode": "pull", 75 "server": "https://images.linuxcontainers.org", 76 "protocol": "lxd", 77 "alias": "ubuntu/xenial/amd64" }).' 78 - 'See U(https://github.com/lxc/lxd/blob/master/doc/rest-api.md#post-1) for complete API documentation.' 79 - 'Note that C(protocol) accepts two choices: C(lxd) or C(simplestreams).' 80 required: false 81 type: dict 82 state: 83 choices: 84 - started 85 - stopped 86 - restarted 87 - absent 88 - frozen 89 description: 90 - Define the state of a container. 91 required: false 92 default: started 93 type: str 94 target: 95 description: 96 - For cluster deployments. Will attempt to create a container on a target node. 97 If container exists elsewhere in a cluster, then container will not be replaced or moved. 98 The name should respond to same name of the node you see in C(lxc cluster list). 99 type: str 100 required: false 101 version_added: 1.0.0 102 timeout: 103 description: 104 - A timeout for changing the state of the container. 105 - This is also used as a timeout for waiting until IPv4 addresses 106 are set to the all network interfaces in the container after 107 starting or restarting. 108 required: false 109 default: 30 110 type: int 111 wait_for_ipv4_addresses: 112 description: 113 - If this is true, the C(lxd_container) waits until IPv4 addresses 114 are set to the all network interfaces in the container after 115 starting or restarting. 116 required: false 117 default: false 118 type: bool 119 force_stop: 120 description: 121 - If this is true, the C(lxd_container) forces to stop the container 122 when it stops or restarts the container. 123 required: false 124 default: false 125 type: bool 126 url: 127 description: 128 - The unix domain socket path or the https URL for the LXD server. 129 required: false 130 default: unix:/var/lib/lxd/unix.socket 131 type: str 132 snap_url: 133 description: 134 - The unix domain socket path when LXD is installed by snap package manager. 135 required: false 136 default: unix:/var/snap/lxd/common/lxd/unix.socket 137 type: str 138 client_key: 139 description: 140 - The client certificate key file path. 141 - If not specified, it defaults to C(${HOME}/.config/lxc/client.key). 142 required: false 143 aliases: [ key_file ] 144 type: path 145 client_cert: 146 description: 147 - The client certificate file path. 148 - If not specified, it defaults to C(${HOME}/.config/lxc/client.crt). 149 required: false 150 aliases: [ cert_file ] 151 type: path 152 trust_password: 153 description: 154 - The client trusted password. 155 - 'You need to set this password on the LXD server before 156 running this module using the following command: 157 C(lxc config set core.trust_password <some random password>). 158 See U(https://www.stgraber.org/2016/04/18/lxd-api-direct-interaction/).' 159 - If trust_password is set, this module send a request for 160 authentication before sending any requests. 161 required: false 162 type: str 163notes: 164 - Containers must have a unique name. If you attempt to create a container 165 with a name that already existed in the users namespace the module will 166 simply return as "unchanged". 167 - There are two ways to run commands in containers, using the command 168 module or using the ansible lxd connection plugin bundled in Ansible >= 169 2.1, the later requires python to be installed in the container which can 170 be done with the command module. 171 - You can copy a file from the host to the container 172 with the Ansible M(ansible.builtin.copy) and M(ansible.builtin.template) module and the `lxd` connection plugin. 173 See the example below. 174 - You can copy a file in the created container to the localhost 175 with `command=lxc file pull container_name/dir/filename filename`. 176 See the first example below. 177''' 178 179EXAMPLES = ''' 180# An example for creating a Ubuntu container and install python 181- hosts: localhost 182 connection: local 183 tasks: 184 - name: Create a started container 185 community.general.lxd_container: 186 name: mycontainer 187 ignore_volatile_options: true 188 state: started 189 source: 190 type: image 191 mode: pull 192 server: https://images.linuxcontainers.org 193 protocol: lxd # if you get a 404, try setting protocol: simplestreams 194 alias: ubuntu/xenial/amd64 195 profiles: ["default"] 196 wait_for_ipv4_addresses: true 197 timeout: 600 198 199 - name: Check python is installed in container 200 delegate_to: mycontainer 201 ansible.builtin.raw: dpkg -s python 202 register: python_install_check 203 failed_when: python_install_check.rc not in [0, 1] 204 changed_when: false 205 206 - name: Install python in container 207 delegate_to: mycontainer 208 ansible.builtin.raw: apt-get install -y python 209 when: python_install_check.rc == 1 210 211# An example for creating an Ubuntu 14.04 container using an image fingerprint. 212# This requires changing 'server' and 'protocol' key values, replacing the 213# 'alias' key with with 'fingerprint' and supplying an appropriate value that 214# matches the container image you wish to use. 215- hosts: localhost 216 connection: local 217 tasks: 218 - name: Create a started container 219 community.general.lxd_container: 220 name: mycontainer 221 ignore_volatile_options: true 222 state: started 223 source: 224 type: image 225 mode: pull 226 # Provides current (and older) Ubuntu images with listed fingerprints 227 server: https://cloud-images.ubuntu.com/releases 228 # Protocol used by 'ubuntu' remote (as shown by 'lxc remote list') 229 protocol: simplestreams 230 # This provides an Ubuntu 14.04 LTS amd64 image from 20150814. 231 fingerprint: e9a8bdfab6dc 232 profiles: ["default"] 233 wait_for_ipv4_addresses: true 234 timeout: 600 235 236# An example for deleting a container 237- hosts: localhost 238 connection: local 239 tasks: 240 - name: Delete a container 241 community.general.lxd_container: 242 name: mycontainer 243 state: absent 244 245# An example for restarting a container 246- hosts: localhost 247 connection: local 248 tasks: 249 - name: Restart a container 250 community.general.lxd_container: 251 name: mycontainer 252 state: restarted 253 254# An example for restarting a container using https to connect to the LXD server 255- hosts: localhost 256 connection: local 257 tasks: 258 - name: Restart a container 259 community.general.lxd_container: 260 url: https://127.0.0.1:8443 261 # These client_cert and client_key values are equal to the default values. 262 #client_cert: "{{ lookup('env', 'HOME') }}/.config/lxc/client.crt" 263 #client_key: "{{ lookup('env', 'HOME') }}/.config/lxc/client.key" 264 trust_password: mypassword 265 name: mycontainer 266 state: restarted 267 268# Note your container must be in the inventory for the below example. 269# 270# [containers] 271# mycontainer ansible_connection=lxd 272# 273- hosts: 274 - mycontainer 275 tasks: 276 - name: Copy /etc/hosts in the created container to localhost with name "mycontainer-hosts" 277 ansible.builtin.fetch: 278 src: /etc/hosts 279 dest: /tmp/mycontainer-hosts 280 flat: true 281 282# An example for LXD cluster deployments. This example will create two new container on specific 283# nodes - 'node01' and 'node02'. In 'target:', 'node01' and 'node02' are names of LXD cluster 284# members that LXD cluster recognizes, not ansible inventory names, see: 'lxc cluster list'. 285# LXD API calls can be made to any LXD member, in this example, we send API requests to 286#'node01.example.com', which matches ansible inventory name. 287- hosts: node01.example.com 288 tasks: 289 - name: Create LXD container 290 community.general.lxd_container: 291 name: new-container-1 292 ignore_volatile_options: true 293 state: started 294 source: 295 type: image 296 mode: pull 297 alias: ubuntu/xenial/amd64 298 target: node01 299 300 - name: Create container on another node 301 community.general.lxd_container: 302 name: new-container-2 303 ignore_volatile_options: true 304 state: started 305 source: 306 type: image 307 mode: pull 308 alias: ubuntu/xenial/amd64 309 target: node02 310''' 311 312RETURN = ''' 313addresses: 314 description: Mapping from the network device name to a list of IPv4 addresses in the container 315 returned: when state is started or restarted 316 type: dict 317 sample: {"eth0": ["10.155.92.191"]} 318old_state: 319 description: The old state of the container 320 returned: when state is started or restarted 321 type: str 322 sample: "stopped" 323logs: 324 description: The logs of requests and responses. 325 returned: when ansible-playbook is invoked with -vvvv. 326 type: list 327 sample: "(too long to be placed here)" 328actions: 329 description: List of actions performed for the container. 330 returned: success 331 type: list 332 sample: '["create", "start"]' 333''' 334import datetime 335import os 336import time 337 338from ansible.module_utils.basic import AnsibleModule 339from ansible_collections.community.general.plugins.module_utils.lxd import LXDClient, LXDClientException 340from ansible.module_utils.six.moves.urllib.parse import urlencode 341 342# LXD_ANSIBLE_STATES is a map of states that contain values of methods used 343# when a particular state is evoked. 344LXD_ANSIBLE_STATES = { 345 'started': '_started', 346 'stopped': '_stopped', 347 'restarted': '_restarted', 348 'absent': '_destroyed', 349 'frozen': '_frozen' 350} 351 352# ANSIBLE_LXD_STATES is a map of states of lxd containers to the Ansible 353# lxc_container module state parameter value. 354ANSIBLE_LXD_STATES = { 355 'Running': 'started', 356 'Stopped': 'stopped', 357 'Frozen': 'frozen', 358} 359 360# ANSIBLE_LXD_DEFAULT_URL is a default value of the lxd endpoint 361ANSIBLE_LXD_DEFAULT_URL = 'unix:/var/lib/lxd/unix.socket' 362 363# CONFIG_PARAMS is a list of config attribute names. 364CONFIG_PARAMS = [ 365 'architecture', 'config', 'devices', 'ephemeral', 'profiles', 'source' 366] 367 368 369class LXDContainerManagement(object): 370 def __init__(self, module): 371 """Management of LXC containers via Ansible. 372 373 :param module: Processed Ansible Module. 374 :type module: ``object`` 375 """ 376 self.module = module 377 self.name = self.module.params['name'] 378 self._build_config() 379 380 self.state = self.module.params['state'] 381 382 self.timeout = self.module.params['timeout'] 383 self.wait_for_ipv4_addresses = self.module.params['wait_for_ipv4_addresses'] 384 self.force_stop = self.module.params['force_stop'] 385 self.addresses = None 386 self.target = self.module.params['target'] 387 388 self.key_file = self.module.params.get('client_key') 389 if self.key_file is None: 390 self.key_file = '{0}/.config/lxc/client.key'.format(os.environ['HOME']) 391 self.cert_file = self.module.params.get('client_cert') 392 if self.cert_file is None: 393 self.cert_file = '{0}/.config/lxc/client.crt'.format(os.environ['HOME']) 394 self.debug = self.module._verbosity >= 4 395 396 try: 397 if self.module.params['url'] != ANSIBLE_LXD_DEFAULT_URL: 398 self.url = self.module.params['url'] 399 elif os.path.exists(self.module.params['snap_url'].replace('unix:', '')): 400 self.url = self.module.params['snap_url'] 401 else: 402 self.url = self.module.params['url'] 403 except Exception as e: 404 self.module.fail_json(msg=e.msg) 405 406 try: 407 self.client = LXDClient( 408 self.url, key_file=self.key_file, cert_file=self.cert_file, 409 debug=self.debug 410 ) 411 except LXDClientException as e: 412 self.module.fail_json(msg=e.msg) 413 self.trust_password = self.module.params.get('trust_password', None) 414 self.actions = [] 415 416 def _build_config(self): 417 self.config = {} 418 for attr in CONFIG_PARAMS: 419 param_val = self.module.params.get(attr, None) 420 if param_val is not None: 421 self.config[attr] = param_val 422 423 def _get_container_json(self): 424 return self.client.do( 425 'GET', '/1.0/containers/{0}'.format(self.name), 426 ok_error_codes=[404] 427 ) 428 429 def _get_container_state_json(self): 430 return self.client.do( 431 'GET', '/1.0/containers/{0}/state'.format(self.name), 432 ok_error_codes=[404] 433 ) 434 435 @staticmethod 436 def _container_json_to_module_state(resp_json): 437 if resp_json['type'] == 'error': 438 return 'absent' 439 return ANSIBLE_LXD_STATES[resp_json['metadata']['status']] 440 441 def _change_state(self, action, force_stop=False): 442 body_json = {'action': action, 'timeout': self.timeout} 443 if force_stop: 444 body_json['force'] = True 445 return self.client.do('PUT', '/1.0/containers/{0}/state'.format(self.name), body_json=body_json) 446 447 def _create_container(self): 448 config = self.config.copy() 449 config['name'] = self.name 450 if self.target: 451 self.client.do('POST', '/1.0/containers?' + urlencode(dict(target=self.target)), config) 452 else: 453 self.client.do('POST', '/1.0/containers', config) 454 self.actions.append('create') 455 456 def _start_container(self): 457 self._change_state('start') 458 self.actions.append('start') 459 460 def _stop_container(self): 461 self._change_state('stop', self.force_stop) 462 self.actions.append('stop') 463 464 def _restart_container(self): 465 self._change_state('restart', self.force_stop) 466 self.actions.append('restart') 467 468 def _delete_container(self): 469 self.client.do('DELETE', '/1.0/containers/{0}'.format(self.name)) 470 self.actions.append('delete') 471 472 def _freeze_container(self): 473 self._change_state('freeze') 474 self.actions.append('freeze') 475 476 def _unfreeze_container(self): 477 self._change_state('unfreeze') 478 self.actions.append('unfreez') 479 480 def _container_ipv4_addresses(self, ignore_devices=None): 481 ignore_devices = ['lo'] if ignore_devices is None else ignore_devices 482 483 resp_json = self._get_container_state_json() 484 network = resp_json['metadata']['network'] or {} 485 network = dict((k, v) for k, v in network.items() if k not in ignore_devices) or {} 486 addresses = dict((k, [a['address'] for a in v['addresses'] if a['family'] == 'inet']) for k, v in network.items()) or {} 487 return addresses 488 489 @staticmethod 490 def _has_all_ipv4_addresses(addresses): 491 return len(addresses) > 0 and all(len(v) > 0 for v in addresses.values()) 492 493 def _get_addresses(self): 494 try: 495 due = datetime.datetime.now() + datetime.timedelta(seconds=self.timeout) 496 while datetime.datetime.now() < due: 497 time.sleep(1) 498 addresses = self._container_ipv4_addresses() 499 if self._has_all_ipv4_addresses(addresses): 500 self.addresses = addresses 501 return 502 except LXDClientException as e: 503 e.msg = 'timeout for getting IPv4 addresses' 504 raise 505 506 def _started(self): 507 if self.old_state == 'absent': 508 self._create_container() 509 self._start_container() 510 else: 511 if self.old_state == 'frozen': 512 self._unfreeze_container() 513 elif self.old_state == 'stopped': 514 self._start_container() 515 if self._needs_to_apply_container_configs(): 516 self._apply_container_configs() 517 if self.wait_for_ipv4_addresses: 518 self._get_addresses() 519 520 def _stopped(self): 521 if self.old_state == 'absent': 522 self._create_container() 523 else: 524 if self.old_state == 'stopped': 525 if self._needs_to_apply_container_configs(): 526 self._start_container() 527 self._apply_container_configs() 528 self._stop_container() 529 else: 530 if self.old_state == 'frozen': 531 self._unfreeze_container() 532 if self._needs_to_apply_container_configs(): 533 self._apply_container_configs() 534 self._stop_container() 535 536 def _restarted(self): 537 if self.old_state == 'absent': 538 self._create_container() 539 self._start_container() 540 else: 541 if self.old_state == 'frozen': 542 self._unfreeze_container() 543 if self._needs_to_apply_container_configs(): 544 self._apply_container_configs() 545 self._restart_container() 546 if self.wait_for_ipv4_addresses: 547 self._get_addresses() 548 549 def _destroyed(self): 550 if self.old_state != 'absent': 551 if self.old_state == 'frozen': 552 self._unfreeze_container() 553 if self.old_state != 'stopped': 554 self._stop_container() 555 self._delete_container() 556 557 def _frozen(self): 558 if self.old_state == 'absent': 559 self._create_container() 560 self._start_container() 561 self._freeze_container() 562 else: 563 if self.old_state == 'stopped': 564 self._start_container() 565 if self._needs_to_apply_container_configs(): 566 self._apply_container_configs() 567 self._freeze_container() 568 569 def _needs_to_change_container_config(self, key): 570 if key not in self.config: 571 return False 572 if key == 'config' and self.ignore_volatile_options: # the old behavior is to ignore configurations by keyword "volatile" 573 old_configs = dict((k, v) for k, v in self.old_container_json['metadata'][key].items() if not k.startswith('volatile.')) 574 for k, v in self.config['config'].items(): 575 if k not in old_configs: 576 return True 577 if old_configs[k] != v: 578 return True 579 return False 580 elif key == 'config': # next default behavior 581 old_configs = dict((k, v) for k, v in self.old_container_json['metadata'][key].items()) 582 for k, v in self.config['config'].items(): 583 if k not in old_configs: 584 return True 585 if old_configs[k] != v: 586 return True 587 return False 588 else: 589 old_configs = self.old_container_json['metadata'][key] 590 return self.config[key] != old_configs 591 592 def _needs_to_apply_container_configs(self): 593 return ( 594 self._needs_to_change_container_config('architecture') or 595 self._needs_to_change_container_config('config') or 596 self._needs_to_change_container_config('ephemeral') or 597 self._needs_to_change_container_config('devices') or 598 self._needs_to_change_container_config('profiles') 599 ) 600 601 def _apply_container_configs(self): 602 old_metadata = self.old_container_json['metadata'] 603 body_json = { 604 'architecture': old_metadata['architecture'], 605 'config': old_metadata['config'], 606 'devices': old_metadata['devices'], 607 'profiles': old_metadata['profiles'] 608 } 609 if self._needs_to_change_container_config('architecture'): 610 body_json['architecture'] = self.config['architecture'] 611 if self._needs_to_change_container_config('config'): 612 for k, v in self.config['config'].items(): 613 body_json['config'][k] = v 614 if self._needs_to_change_container_config('ephemeral'): 615 body_json['ephemeral'] = self.config['ephemeral'] 616 if self._needs_to_change_container_config('devices'): 617 body_json['devices'] = self.config['devices'] 618 if self._needs_to_change_container_config('profiles'): 619 body_json['profiles'] = self.config['profiles'] 620 self.client.do('PUT', '/1.0/containers/{0}'.format(self.name), body_json=body_json) 621 self.actions.append('apply_container_configs') 622 623 def run(self): 624 """Run the main method.""" 625 626 try: 627 if self.trust_password is not None: 628 self.client.authenticate(self.trust_password) 629 self.ignore_volatile_options = self.module.params.get('ignore_volatile_options') 630 631 self.old_container_json = self._get_container_json() 632 self.old_state = self._container_json_to_module_state(self.old_container_json) 633 action = getattr(self, LXD_ANSIBLE_STATES[self.state]) 634 action() 635 636 state_changed = len(self.actions) > 0 637 result_json = { 638 'log_verbosity': self.module._verbosity, 639 'changed': state_changed, 640 'old_state': self.old_state, 641 'actions': self.actions 642 } 643 if self.client.debug: 644 result_json['logs'] = self.client.logs 645 if self.addresses is not None: 646 result_json['addresses'] = self.addresses 647 self.module.exit_json(**result_json) 648 except LXDClientException as e: 649 state_changed = len(self.actions) > 0 650 fail_params = { 651 'msg': e.msg, 652 'changed': state_changed, 653 'actions': self.actions 654 } 655 if self.client.debug: 656 fail_params['logs'] = e.kwargs['logs'] 657 self.module.fail_json(**fail_params) 658 659 660def main(): 661 """Ansible Main module.""" 662 663 module = AnsibleModule( 664 argument_spec=dict( 665 name=dict( 666 type='str', 667 required=True 668 ), 669 architecture=dict( 670 type='str', 671 ), 672 config=dict( 673 type='dict', 674 ), 675 ignore_volatile_options=dict( 676 type='bool', 677 default=True 678 ), 679 devices=dict( 680 type='dict', 681 ), 682 ephemeral=dict( 683 type='bool', 684 ), 685 profiles=dict( 686 type='list', 687 elements='str', 688 ), 689 source=dict( 690 type='dict', 691 ), 692 state=dict( 693 choices=list(LXD_ANSIBLE_STATES.keys()), 694 default='started' 695 ), 696 target=dict( 697 type='str', 698 ), 699 timeout=dict( 700 type='int', 701 default=30 702 ), 703 wait_for_ipv4_addresses=dict( 704 type='bool', 705 default=False 706 ), 707 force_stop=dict( 708 type='bool', 709 default=False 710 ), 711 url=dict( 712 type='str', 713 default=ANSIBLE_LXD_DEFAULT_URL 714 ), 715 snap_url=dict( 716 type='str', 717 default='unix:/var/snap/lxd/common/lxd/unix.socket' 718 ), 719 client_key=dict( 720 type='path', 721 aliases=['key_file'] 722 ), 723 client_cert=dict( 724 type='path', 725 aliases=['cert_file'] 726 ), 727 trust_password=dict(type='str', no_log=True) 728 ), 729 supports_check_mode=False, 730 ) 731 # if module.params['ignore_volatile_options'] is None: 732 # module.params['ignore_volatile_options'] = True 733 # module.deprecate( 734 # 'If the keyword "volatile" is used in a playbook in the config section, a 735 # "changed" message will appear with every run, even without a change to the playbook. 736 # This will change in the future. 737 # Please test your scripts by "ignore_volatile_options: false"', version='5.0.0', collection_name='community.general') 738 lxd_manage = LXDContainerManagement(module=module) 739 lxd_manage.run() 740 741 742if __name__ == '__main__': 743 main() 744