diff --git a/scripts/create-sc-instance b/scripts/create-sc-instance new file mode 100755 index 00000000..62d0130a --- /dev/null +++ b/scripts/create-sc-instance @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +# pylint: disable=invalid-name +# pylint: enable=invalid-name +""" +Tool used for creating CNaaS safespring machines the way we expect them to be created + +It is formatted and linted in the following way: +=== +black create-cnaas-instance +isort --profile black create-cnaas-instance +mypy --python-executable /opt/homebrew/bin/python3 --strict create-cnaas-instance +PYTHONPATH=/opt/homebrew/lib/python3.10/site-packages pylint create-cnaas-instance +=== +""" + +# Make code work on python 3.7 +from __future__ import annotations + +import argparse +import base64 +import ipaddress +import re +import string +import sys +import textwrap +import typing +from typing import Any, Union + +try: + from typing import TypeGuard +except ImportError: + from typing_extensions import ( # use `typing_extensions` for Python 3.9 and below + TypeGuard, + ) + +import openstack +from openstack.compute.v2.flavor import Flavor +from openstack.compute.v2.image import Image +from openstack.compute.v2.keypair import Keypair +from openstack.compute.v2.server import Server +from openstack.compute.v2.server_group import ServerGroup + +# Make mypy type checking happy, also helps with go-to-definition support in editor +from openstack.connection import Connection +from openstack.exceptions import ConflictException +from openstack.network.v2.network import Network +from openstack.network.v2.port import Port +from openstack.network.v2.security_group import SecurityGroup + + +def get_keypair(conn: Connection, keypair_name: str) -> Keypair: + """Lookup already existing keypair by name and return it""" + keypair = conn.compute.find_keypair(keypair_name) + + if not keypair: + raise RuntimeError(f"unable to find keypair with name '{keypair_name}'") + + if not isinstance(keypair, Keypair): + raise TypeError(f"keypair should be {Keypair} but is {type(keypair)}") + + return keypair + + +def get_port( # pylint:disable=too-many-arguments,too-many-branches + args: argparse.Namespace, + conn: Connection, + network: Network, + port_name: str, +) -> Port: + """Either create a new named port or return already existing one""" + + for port in conn.network.ports(): + if not isinstance(port, Port): + raise TypeError(f"port should be {Port} but is {type(port)}") + if port.name == port_name: + print(f"named port already exists, using it: {port.name} ({port.id})") + return port + + print(f"creating port: {port_name}") + port = conn.network.create_port( + network_id=network.id, name=port_name + ) + if not isinstance(port, Port): + raise TypeError(f"port should be {Port} but is {type(port)}") + + return port + + +def is_int_list(test_list: list[Any]) -> TypeGuard[list[int]]: + """Help with typechecking a list of ints""" + return all(isinstance(element, int) for element in test_list) + + +def create_server( # pylint: disable=too-many-arguments, too-many-locals + conn: Connection, + server_name: str, + port: Port, + image: Image, + flavor: Flavor, + keypair: Keypair, + disk_size: int, +) -> Server: + """Create a new server""" + + print(f"creating server: {server_name}") + + disk_sizes = [disk_size] + + block_mappings = [] + + if flavor.disk == 0: + print( + f"flavor '{flavor.name}' includes no disk, using block storage for all disks" + ) + volume_type_name = "fast" + #volume_type = conn.volume.find_type(volume_type_name) + #if volume_type is None: + #raise RuntimeError(f"unable to find volume type '{volume_type_name}'") + for i, size in enumerate(disk_sizes): + block_mapping = { + "delete_on_termination": True, + "destination_type": "volume", + "volume_size": size, + #"volume_type": volume_type.name, + } + # The first disk is the OS disk, base it on the selected image and bootable + if i == 0: + block_mapping["boot_index"] = 0 + block_mapping["source_type"] = "image" + block_mapping["uuid"] = image.id + else: + # Any aditional disk is a data disk, should not be booted from + # and has no automatic contents + block_mapping["source_type"] = "blank" + + block_mappings.append(block_mapping) + server = conn.compute.create_server( + name=server_name, + flavor_id=flavor.id, + networks=[{"port": port.id}], + key_name=keypair.name, + block_device_mapping_v2=block_mappings, + disk_config="AUTO", + ) + else: + print(f"flavor '{flavor.name}' includes disk, using local storage") + server = conn.compute.create_server( + name=server_name, + image_id=image.id, + flavor_id=flavor.id, + networks=[{"port": port.id}], + key_name=keypair.name, + ) + + print(f"waiting for server to be active: {server_name}") + server = conn.compute.wait_for_server(server) + print(f"server is active: {server_name}") + + if not isinstance(server, Server): + raise TypeError(f"server should be {Server} but is {type(server)}") + + return server + +def main() -> ( + None +): # pylint: disable=too-many-locals,too-many-statements,too-many-branches + """Starting point of the program""" + + parser = argparse.ArgumentParser() + parser.add_argument( + "--cloud", + help="name of the openstack cloud in your clouds.yaml, e.g: openstack", + required=True, + ) + parser.add_argument( + "--flavor", + help="name of the instance flavor to use, e.g: b2.c1r2", + required=False, + ) + parser.add_argument( + "--image", + help="name of the instance image to use, e.g: ubuntu-20.04", + required=False, + ) + parser.add_argument( + "--fqdn", + help="full name of server to create, e.g: example-ni1.cnaas.sunet.se", + required=True, + ) + parser.add_argument( + "--key-name", + help="name of existing SSH keypair to deploy server with, e.g: uid-yk1", + required=True, + ) + parser.add_argument( + "--disk-size", + help="provide disk size in GB", + required=True, + ) + parser.add_argument( + "--verbose", + help="include additional informational messages about what is being done", + required=False, + action="store_true", + ) + parser.add_argument( + "--debug", + help="enable debug logging for openstack calls " + "(lots of output, potentially sensitive information)", + required=False, + action="store_true", + ) + args = parser.parse_args() + + if not args.flavor: + print( + "you must supply --flavor", + ) + sys.exit(1) + + flavor_name = args.flavor + + if not args.image: + print( + "you must supply --image", + ) + sys.exit(1) + + image_name = args.image + + # Configure logging. Keep in mind that debug logging will print sensitive + # information like passwords to the screen. + openstack.enable_logging(debug=args.debug) # type: ignore + + # Initialize connection + conn = openstack.connect(cloud=args.cloud) + + keypair = get_keypair(conn, args.key_name) + + network_name = "public" + + server = conn.compute.find_server(args.fqdn) + if server is not None: + print(f"server '{args.fqdn}' already exists") + sys.exit(1) + + image = conn.image.find_image(image_name) + if image is None: + print(f"image '{image_name}' not found") + sys.exit(1) + + flavor = conn.compute.find_flavor(flavor_name) + if flavor is None: + print(f"flavor '{flavor_name}' not found") + sys.exit(1) + + network = conn.network.find_network(network_name) + if network is None: + print(f"network '{network_name}' not found") + sys.exit(1) + + port = get_port( + args, + conn, + network, + args.fqdn, + ) + + create_server( + conn, + args.fqdn, + port, + image, + flavor, + keypair, + args.disk_size, + ) + + print("DNS records (needs to be created manually):") + for fixed_ip in port.fixed_ips: + address = ipaddress.ip_address(fixed_ip["ip_address"]) + if isinstance(address, ipaddress.IPv4Address): + print(f"{args.fqdn}. A {address}") + if isinstance(address, ipaddress.IPv6Address): + print(f"{args.fqdn}. AAAA {address}") + + +if __name__ == "__main__": + main()