#!/usr/bin/python # Copyright Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import annotations DOCUMENTATION = r""" module: linode short_description: Manage instances on the Linode Public Cloud description: - Manage Linode Public Cloud instances and optionally wait for it to be 'running'. extends_documentation_fragment: - community.general.attributes attributes: check_mode: support: none diff_mode: support: none options: state: description: - Indicate desired state of the resource. choices: [absent, active, deleted, present, restarted, started, stopped] default: present type: str api_key: description: - Linode API key. - E(LINODE_API_KEY) environment variable can be used instead. type: str required: true name: description: - Name to give the instance (alphanumeric, dashes, underscore). - To keep sanity on the Linode Web Console, name is prepended with C(LinodeID-). required: true type: str displaygroup: description: - Add the instance to a Display Group in Linode Manager. type: str default: '' linode_id: description: - Unique ID of a Linode server. This value is read-only in the sense that if you specify it on creation of a Linode it is not used. The Linode API generates these IDs and we can those generated value here to reference a Linode more specifically. This is useful for idempotency. aliases: [lid] type: int additional_disks: description: - List of dictionaries for creating additional disks that are added to the Linode configuration settings. - Dictionary takes Size, Label, Type. Size is in MB. type: list elements: dict alert_bwin_enabled: description: - Set status of bandwidth in alerts. type: bool alert_bwin_threshold: description: - Set threshold in MB of bandwidth in alerts. type: int alert_bwout_enabled: description: - Set status of bandwidth out alerts. type: bool alert_bwout_threshold: description: - Set threshold in MB of bandwidth out alerts. type: int alert_bwquota_enabled: description: - Set status of bandwidth quota alerts as percentage of network transfer quota. type: bool alert_bwquota_threshold: description: - Set threshold in MB of bandwidth quota alerts. type: int alert_cpu_enabled: description: - Set status of receiving CPU usage alerts. type: bool alert_cpu_threshold: description: - Set percentage threshold for receiving CPU usage alerts. Each CPU core adds 100% to total. type: int alert_diskio_enabled: description: - Set status of receiving disk IO alerts. type: bool alert_diskio_threshold: description: - Set threshold for average IO ops/sec over 2 hour period. type: int backupweeklyday: description: - Day of the week to take backups. type: int backupwindow: description: - The time window in which backups are taken. type: int plan: description: - Plan to use for the instance (Linode plan). type: int payment_term: description: - Payment term to use for the instance (payment term in months). default: 1 choices: [1, 12, 24] type: int password: description: - Root password to apply to a new server (auto generated if missing). type: str private_ip: description: - Add private IPv4 address when Linode is created. - Default is V(false). type: bool ssh_pub_key: description: - SSH public key applied to root user. type: str swap: description: - Swap size in MB. default: 512 type: int distribution: description: - Distribution to use for the instance (Linode Distribution). type: int datacenter: description: - Datacenter to create an instance in (Linode Datacenter). type: int kernel_id: description: - Kernel to use for the instance (Linode Kernel). type: int wait: description: - Wait for the instance to be in state V(running) before returning. type: bool default: true wait_timeout: description: - How long before wait gives up, in seconds. default: 300 type: int watchdog: description: - Set status of Lassie watchdog. type: bool default: true requirements: - linode-python author: - Vincent Viallet (@zbal) notes: - Please note, linode-python does not have python 3 support. - This module uses the now deprecated v3 of the Linode API. - Please review U(https://www.linode.com/api/linode) for determining the required parameters. """ EXAMPLES = r""" - name: Create a new Linode community.general.linode: name: linode-test1 plan: 1 datacenter: 7 distribution: 129 state: present register: linode_creation - name: Create a server with a private IP Address community.general.linode: module: linode api_key: 'longStringFromLinodeApi' name: linode-test1 plan: 1 datacenter: 2 distribution: 99 password: 'superSecureRootPassword' private_ip: true ssh_pub_key: 'ssh-rsa qwerty' swap: 768 wait: true wait_timeout: 600 state: present delegate_to: localhost register: linode_creation - name: Fully configure new server community.general.linode: api_key: 'longStringFromLinodeApi' name: linode-test1 plan: 4 datacenter: 2 distribution: 99 kernel_id: 138 password: 'superSecureRootPassword' private_ip: true ssh_pub_key: 'ssh-rsa qwerty' swap: 768 wait: true wait_timeout: 600 state: present alert_bwquota_enabled: true alert_bwquota_threshold: 80 alert_bwin_enabled: true alert_bwin_threshold: 10 alert_cpu_enabled: true alert_cpu_threshold: 210 alert_bwout_enabled: true alert_bwout_threshold: 10 alert_diskio_enabled: true alert_diskio_threshold: 10000 backupweeklyday: 1 backupwindow: 2 displaygroup: 'test' additional_disks: - {Label: 'disk1', Size: 2500, Type: 'raw'} - {Label: 'newdisk', Size: 2000} watchdog: true delegate_to: localhost register: linode_creation - name: Ensure a running server (create if missing) community.general.linode: api_key: 'longStringFromLinodeApi' name: linode-test1 plan: 1 datacenter: 2 distribution: 99 password: 'superSecureRootPassword' ssh_pub_key: 'ssh-rsa qwerty' swap: 768 wait: true wait_timeout: 600 state: present delegate_to: localhost register: linode_creation - name: Delete a server community.general.linode: api_key: 'longStringFromLinodeApi' name: linode-test1 linode_id: "{{ linode_creation.instance.id }}" state: absent delegate_to: localhost - name: Stop a server community.general.linode: api_key: 'longStringFromLinodeApi' name: linode-test1 linode_id: "{{ linode_creation.instance.id }}" state: stopped delegate_to: localhost - name: Reboot a server community.general.linode: api_key: 'longStringFromLinodeApi' name: linode-test1 linode_id: "{{ linode_creation.instance.id }}" state: restarted delegate_to: localhost """ import random import string import time import traceback LINODE_IMP_ERR = None try: from linode import api as linode_api HAS_LINODE = True except ImportError: LINODE_IMP_ERR = traceback.format_exc() HAS_LINODE = False from ansible.module_utils.basic import AnsibleModule, env_fallback, missing_required_lib def randompass(): """ Generate a long random password that comply to Linode requirements """ # Linode API currently requires the following: # It must contain at least two of these four character classes: # lower case letters - upper case letters - numbers - punctuation # we play it safe :) # as of python 2.4, this reseeds the PRNG from urandom random.seed() lower = "".join(random.choice(string.ascii_lowercase) for x in range(6)) upper = "".join(random.choice(string.ascii_uppercase) for x in range(6)) number = "".join(random.choice(string.digits) for x in range(6)) punct = "".join(random.choice(string.punctuation) for x in range(6)) p = lower + upper + number + punct return "".join(random.sample(p, len(p))) def getInstanceDetails(api, server): """ Return the details of an instance, populating IPs, etc. """ instance = {"id": server["LINODEID"], "name": server["LABEL"], "public": [], "private": []} # Populate with ips for ip in api.linode_ip_list(LinodeId=server["LINODEID"]): if ip["ISPUBLIC"] and "ipv4" not in instance: instance["ipv4"] = ip["IPADDRESS"] instance["fqdn"] = ip["RDNS_NAME"] if ip["ISPUBLIC"]: instance["public"].append({"ipv4": ip["IPADDRESS"], "fqdn": ip["RDNS_NAME"], "ip_id": ip["IPADDRESSID"]}) else: instance["private"].append({"ipv4": ip["IPADDRESS"], "fqdn": ip["RDNS_NAME"], "ip_id": ip["IPADDRESSID"]}) return instance def linodeServers( module, api, state, name, displaygroup, plan, additional_disks, distribution, datacenter, kernel_id, linode_id, payment_term, password, private_ip, ssh_pub_key, swap, wait, wait_timeout, watchdog, **kwargs, ): instances = [] changed = False new_server = False servers = [] disks = [] configs = [] jobs = [] # See if we can match an existing server details with the provided linode_id if linode_id: # For the moment we only consider linode_id as criteria for match # Later we can use more (size, name, etc.) and update existing servers = api.linode_list(LinodeId=linode_id) # Attempt to fetch details about disks and configs only if servers are # found with linode_id if servers: disks = api.linode_disk_list(LinodeId=linode_id) configs = api.linode_config_list(LinodeId=linode_id) # Act on the state if state in ("active", "present", "started"): # TODO: validate all the plan / distribution / datacenter are valid # Multi step process/validation: # - need linode_id (entity) # - need disk_id for linode_id - create disk from distrib # - need config_id for linode_id - create config (need kernel) # Any create step triggers a job that need to be waited for. if not servers: for arg in (name, plan, distribution, datacenter): if not arg: module.fail_json(msg=f"{arg} is required for {state} state") # Create linode entity new_server = True # Get size of all individually listed disks to subtract from Distribution disk used_disk_space = 0 if additional_disks is None else sum(disk["Size"] for disk in additional_disks) try: res = api.linode_create(DatacenterID=datacenter, PlanID=plan, PaymentTerm=payment_term) linode_id = res["LinodeID"] # Update linode Label to match name api.linode_update(LinodeId=linode_id, Label=f"{linode_id}-{name}") # Update Linode with Ansible configuration options api.linode_update(LinodeId=linode_id, LPM_DISPLAYGROUP=displaygroup, WATCHDOG=watchdog, **kwargs) # Save server servers = api.linode_list(LinodeId=linode_id) except Exception as e: module.fail_json(msg=f"{e.value[0]['ERRORMESSAGE']}") # Add private IP to Linode if private_ip: try: res = api.linode_ip_addprivate(LinodeID=linode_id) except Exception as e: module.fail_json(msg=f"{e.value[0]['ERRORMESSAGE']}", exception=traceback.format_exc()) if not disks: for arg in (name, linode_id, distribution): if not arg: module.fail_json(msg=f"{arg} is required for {state} state") # Create disks (1 from distrib, 1 for SWAP) new_server = True try: if not password: # Password is required on creation, if not provided generate one password = randompass() if not swap: swap = 512 # Create data disk size = servers[0]["TOTALHD"] - used_disk_space - swap if ssh_pub_key: res = api.linode_disk_createfromdistribution( LinodeId=linode_id, DistributionID=distribution, rootPass=password, rootSSHKey=ssh_pub_key, Label=f"{name} data disk (lid: {linode_id})", Size=size, ) else: res = api.linode_disk_createfromdistribution( LinodeId=linode_id, DistributionID=distribution, rootPass=password, Label=f"{name} data disk (lid: {linode_id})", Size=size, ) jobs.append(res["JobID"]) # Create SWAP disk res = api.linode_disk_create( LinodeId=linode_id, Type="swap", Label=f"{name} swap disk (lid: {linode_id})", Size=swap ) # Create individually listed disks at specified size if additional_disks: for disk in additional_disks: # If a disk Type is not passed in, default to ext4 if disk.get("Type") is None: disk["Type"] = "ext4" res = api.linode_disk_create( LinodeID=linode_id, Label=disk["Label"], Size=disk["Size"], Type=disk["Type"] ) jobs.append(res["JobID"]) except Exception as e: # TODO: destroy linode ? module.fail_json(msg=f"{e.value[0]['ERRORMESSAGE']}", exception=traceback.format_exc()) if not configs: for arg in (name, linode_id, distribution): if not arg: module.fail_json(msg=f"{arg} is required for {state} state") # Check architecture for distrib in api.avail_distributions(): if distrib["DISTRIBUTIONID"] != distribution: continue arch = "32" if distrib["IS64BIT"]: arch = "64" break # Get latest kernel matching arch if kernel_id is not specified if not kernel_id: for kernel in api.avail_kernels(): if not kernel["LABEL"].startswith(f"Latest {arch}"): continue kernel_id = kernel["KERNELID"] break # Get disk list disks_id = [] for disk in api.linode_disk_list(LinodeId=linode_id): if disk["TYPE"] == "ext3": disks_id.insert(0, str(disk["DISKID"])) continue disks_id.append(str(disk["DISKID"])) # Trick to get the 9 items in the list while len(disks_id) < 9: disks_id.append("") disks_list = ",".join(disks_id) # Create config new_server = True try: api.linode_config_create( LinodeId=linode_id, KernelId=kernel_id, Disklist=disks_list, Label=f"{name} config" ) configs = api.linode_config_list(LinodeId=linode_id) except Exception as e: module.fail_json(msg=f"{e.value[0]['ERRORMESSAGE']}", exception=traceback.format_exc()) # Start / Ensure servers are running for server in servers: # Refresh server state server = api.linode_list(LinodeId=server["LINODEID"])[0] # Ensure existing servers are up and running, boot if necessary if server["STATUS"] != 1: res = api.linode_boot(LinodeId=linode_id) jobs.append(res["JobID"]) changed = True # wait here until the instances are up wait_timeout = time.time() + wait_timeout while wait and wait_timeout > time.time(): # refresh the server details server = api.linode_list(LinodeId=server["LINODEID"])[0] # status: # -2: Boot failed # 1: Running if server["STATUS"] in (-2, 1): break time.sleep(5) if wait and wait_timeout <= time.time(): # waiting took too long module.fail_json(msg=f"Timeout waiting on {server['LABEL']} (lid: {server['LINODEID']})") # Get a fresh copy of the server details server = api.linode_list(LinodeId=server["LINODEID"])[0] if server["STATUS"] == -2: module.fail_json(msg=f"{server['LABEL']} (lid: {server['LINODEID']}) failed to boot") # From now on we know the task is a success # Build instance report instance = getInstanceDetails(api, server) # depending on wait flag select the status if wait: instance["status"] = "Running" else: instance["status"] = "Starting" # Return the root password if this is a new box and no SSH key # has been provided if new_server and not ssh_pub_key: instance["password"] = password instances.append(instance) elif state in ("stopped",): if not servers: module.fail_json(msg=f"Server (lid: {linode_id}) not found") for server in servers: instance = getInstanceDetails(api, server) if server["STATUS"] != 2: try: res = api.linode_shutdown(LinodeId=linode_id) except Exception as e: module.fail_json(msg=f"{e.value[0]['ERRORMESSAGE']}", exception=traceback.format_exc()) instance["status"] = "Stopping" changed = True else: instance["status"] = "Stopped" instances.append(instance) elif state in ("restarted",): if not servers: module.fail_json(msg=f"Server (lid: {linode_id}) not found") for server in servers: instance = getInstanceDetails(api, server) try: res = api.linode_reboot(LinodeId=server["LINODEID"]) except Exception as e: module.fail_json(msg=f"{e.value[0]['ERRORMESSAGE']}", exception=traceback.format_exc()) instance["status"] = "Restarting" changed = True instances.append(instance) elif state in ("absent", "deleted"): for server in servers: instance = getInstanceDetails(api, server) try: api.linode_delete(LinodeId=server["LINODEID"], skipChecks=True) except Exception as e: module.fail_json(msg=f"{e.value[0]['ERRORMESSAGE']}", exception=traceback.format_exc()) instance["status"] = "Deleting" changed = True instances.append(instance) # Ease parsing if only 1 instance if len(instances) == 1: module.exit_json(changed=changed, instance=instances[0]) module.exit_json(changed=changed, instances=instances) def main(): module = AnsibleModule( argument_spec=dict( state=dict( type="str", default="present", choices=["absent", "active", "deleted", "present", "restarted", "started", "stopped"], ), api_key=dict(type="str", no_log=True, required=True, fallback=(env_fallback, ["LINODE_API_KEY"])), name=dict(type="str", required=True), alert_bwin_enabled=dict(type="bool"), alert_bwin_threshold=dict(type="int"), alert_bwout_enabled=dict(type="bool"), alert_bwout_threshold=dict(type="int"), alert_bwquota_enabled=dict(type="bool"), alert_bwquota_threshold=dict(type="int"), alert_cpu_enabled=dict(type="bool"), alert_cpu_threshold=dict(type="int"), alert_diskio_enabled=dict(type="bool"), alert_diskio_threshold=dict(type="int"), backupweeklyday=dict(type="int"), backupwindow=dict(type="int"), displaygroup=dict(type="str", default=""), plan=dict(type="int"), additional_disks=dict(type="list", elements="dict"), distribution=dict(type="int"), datacenter=dict(type="int"), kernel_id=dict(type="int"), linode_id=dict(type="int", aliases=["lid"]), payment_term=dict(type="int", default=1, choices=[1, 12, 24]), password=dict(type="str", no_log=True), private_ip=dict(type="bool"), ssh_pub_key=dict(type="str"), swap=dict(type="int", default=512), wait=dict(type="bool", default=True), wait_timeout=dict(type="int", default=300), watchdog=dict(type="bool", default=True), ), required_if=[ ("state", "restarted", ["linode_id"]), ("state", "stopped", ["linode_id"]), ], ) if not HAS_LINODE: module.fail_json(msg=missing_required_lib("linode-python"), exception=LINODE_IMP_ERR) state = module.params.get("state") api_key = module.params.get("api_key") name = module.params.get("name") alert_bwin_enabled = module.params.get("alert_bwin_enabled") alert_bwin_threshold = module.params.get("alert_bwin_threshold") alert_bwout_enabled = module.params.get("alert_bwout_enabled") alert_bwout_threshold = module.params.get("alert_bwout_threshold") alert_bwquota_enabled = module.params.get("alert_bwquota_enabled") alert_bwquota_threshold = module.params.get("alert_bwquota_threshold") alert_cpu_enabled = module.params.get("alert_cpu_enabled") alert_cpu_threshold = module.params.get("alert_cpu_threshold") alert_diskio_enabled = module.params.get("alert_diskio_enabled") alert_diskio_threshold = module.params.get("alert_diskio_threshold") backupweeklyday = module.params.get("backupweeklyday") backupwindow = module.params.get("backupwindow") displaygroup = module.params.get("displaygroup") plan = module.params.get("plan") additional_disks = module.params.get("additional_disks") distribution = module.params.get("distribution") datacenter = module.params.get("datacenter") kernel_id = module.params.get("kernel_id") linode_id = module.params.get("linode_id") payment_term = module.params.get("payment_term") password = module.params.get("password") private_ip = module.params.get("private_ip") ssh_pub_key = module.params.get("ssh_pub_key") swap = module.params.get("swap") wait = module.params.get("wait") wait_timeout = module.params.get("wait_timeout") watchdog = int(module.params.get("watchdog")) check_items = dict( alert_bwin_enabled=alert_bwin_enabled, alert_bwin_threshold=alert_bwin_threshold, alert_bwout_enabled=alert_bwout_enabled, alert_bwout_threshold=alert_bwout_threshold, alert_bwquota_enabled=alert_bwquota_enabled, alert_bwquota_threshold=alert_bwquota_threshold, alert_cpu_enabled=alert_cpu_enabled, alert_cpu_threshold=alert_cpu_threshold, alert_diskio_enabled=alert_diskio_enabled, alert_diskio_threshold=alert_diskio_threshold, backupweeklyday=backupweeklyday, backupwindow=backupwindow, ) kwargs = {k: v for k, v in check_items.items() if v is not None} # setup the auth try: api = linode_api.Api(api_key) api.test_echo() except Exception as e: module.fail_json(msg=f"{e.value[0]['ERRORMESSAGE']}", exception=traceback.format_exc()) linodeServers( module, api, state, name, displaygroup, plan, additional_disks, distribution, datacenter, kernel_id, linode_id, payment_term, password, private_ip, ssh_pub_key, swap, wait, wait_timeout, watchdog, **kwargs, ) if __name__ == "__main__": main()