#!/usr/local/venvs/init_container/bin/python import argparse import ipaddress import logging import os import socket import subprocess import sys import time import pylxd ############################################################################### # begin logger ############################################################################### MYNAME = sys.argv[0] LOGDIR = "~/" + os.path.basename(MYNAME).replace(".py", "_LOG") # noqa: PTH119 EXPENDED_LOGDIR = os.path.expanduser(LOGDIR) # noqa: PTH111 if os.path.isdir(EXPENDED_LOGDIR) is False: # noqa: PTH112 os.mkdir(EXPENDED_LOGDIR) # noqa: PTH102 LOGFILE = EXPENDED_LOGDIR + "/" + time.strftime("%Y-%m-%d") + ".log" # from https://docs.python.org/2/howto/logging-cookbook.html logger = logging.getLogger("jdl") logger.setLevel(logging.DEBUG) # create file handler fh = logging.FileHandler(LOGFILE) fh.setLevel(logging.INFO) # create console handler ch = logging.StreamHandler() ch.setLevel(logging.INFO) # create formatter and add it to the handlers formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") fh.setFormatter(formatter) ch.setFormatter(formatter) # add the handlers to the logger logger.addHandler(fh) logger.addHandler(ch) logger.info("Program Starting") ############################################################################### # end logger - start parser ############################################################################### domain_list = [ "talas.com", "int.talas.veza", "talas.dev", "hds.talas.cloud", "int.talas.cloud", "test.talas.com", "hds.reflex-holding.com", "talas.sh", ] parser = argparse.ArgumentParser(description="script to create a container on Debian or Ubuntu") parser.add_argument("--name", help="name of the container", type=str, required=True) parser.add_argument( "--network", help="network profile name for this container, this script only handle containers with 1 network profile, the name of the profile is usualy 'net-vlanXXX'", type=str, required=True, ) parser.add_argument( "--distrib", help="distribution to use, current default is 'debian/bookworm'. Example: 'ubuntu/focal' 'debian/bookworm', you can see a list of available image with 'lxc image list images:'", type=str, default="debian/bookworm", ) parser.add_argument( "--cpu", help="number of CPU threads for the container, will create the relevant profile if it doesn't exist (default=4)", type=int, default=4, ) parser.add_argument( "--memory", help="memory limit in GiB, will create the relevant profile if it doesn't exist (default=4GiB)", type=int, default=4, ) parser.add_argument("--quota", help="zfs quota in GiB for the root drive (default=10)", type=int, default=10) parser.add_argument( "--pool", help='name of the incus storage pool to use, the default value is "default" ', type=str, default="default" ) parser.add_argument( "--ip", help=f"private IPv4 address, default to the DNS resolution of 'container name' with one of the following domains: {domain_list}. You can specify a mask, if not, a /24 will be used", type=ipaddress.IPv4Interface, ) parser.add_argument( "--public", help="Allow the use of public IP address with the --ip parameter, you will need to set a public DNS server with the --dns option for this to work as intended", action="store_true", ) parser.add_argument( "--gateway", help="IP address of the gateway, if unspecified, will use the last IP of the network", type=ipaddress.ip_address, ) parser.add_argument( "--proxy", help="address of the proxy server, if any, syntax is 'hostname:port' ", type=str, default="no" ) parser.add_argument( "--dns", help="DNS servers to use, the default is to copy the /etc/resolv.conf of the current host", type=ipaddress.ip_address, nargs="+", ) parser.add_argument( "--networkmethod", help="which method should be used to configure the network, supported options are: 'networkd' or 'interfaces', default 'networkd', 'interfaces' should not be used anymore since Debian 11 is now working with networkd", type=str, choices=["networkd", "interfaces"], default="networkd", ) parser.add_argument("--debug", help="enable debug output", action="store_true") args = parser.parse_args() if args.debug: fh.setLevel(logging.DEBUG) ch.setLevel(logging.DEBUG) if args.ip is None: resolved = False for domain in domain_list: try: ip = socket.gethostbyname(f"{args.name}.{domain}") + "/24" args.ip = ipaddress.IPv4Interface(ip) logger.info(f'resolved "{args.name}.{domain}" to "{ip}"') resolved = True break except: # noqa: E722 logger.debug(f'could not resolve "{args.name}.{domain}"') if not resolved: logger.info(f'could not resolve "{args.name}" with any of the following domains: {domain_list}, exiting.') sys.exit(1) ############################################################################### # end parser - start functions ############################################################################### def ping(IP): # noqa: N803 try: CMD = "ping -w 1 " + IP # noqa: N806 subprocess.check_output(CMD, shell=True) logger.error("Specified IP is already up, exiting") return True # noqa: TRY300 except: # noqa: E722 logger.debug("Specified IP is not responding, continuing") return False def check_container_exists(name): try: client.instances.get(name) logger.error("container " + name + " already exists, exiting") return True # noqa: TRY300 except: # noqa: E722 logger.debug("the container doesn't exist, proceeding") return False def wait(duration_in_sec): print("waiting a bit ", end="", flush=True) for n in range(1, duration_in_sec): # noqa: B007 time.sleep(1) print(".", end="", flush=True) print("") ############################################################################### # end functions - start program ############################################################################### # check if a network mask was specified if args.ip.netmask == ipaddress.IPv4Address("255.255.255.255"): logger.debug("no netmask specified, assuming netmask /24") ip = ipaddress.IPv4Interface(str(args.ip.ip) + "/24") else: ip = args.ip # verify that the IP address is private if not ip.is_private and not args.public: logger.error( "IP address " + ip.exploded + " is not a private IPv4 address, use the --public option if you really want to use a public IP" ) sys.exit(1) # is this IP already up? logger.debug(f"trying to ping {ip.ip} from this host") if ping(str(ip.ip)): sys.exit(1) # connect ourselves to the incus daemon try: client = pylxd.Client(endpoint="/var/lib/incus/unix.socket") except: # noqa: E722 logger.error("could not connect to the local incus daemon") # noqa: TRY400 sys.exit(1) # check if the container already exists logger.debug(f"check if a container with the name {args.name} already exists...") if check_container_exists(args.name): sys.exit(1) # check if the storage pool exists logger.debug(f'checking if the storage pool "{args.pool}" exists') if client.storage_pools.exists(args.pool): logger.debug(f'the storage pool "{args.pool}" exists') else: logger.error(f'the storage pool "{args.pool}" does not exists!') sys.exit(1) # check if the network profile already exists logger.debug("check if the network profile exists (it cannot be created automatically to avoid mistakes)") try: client.profiles.get(args.network) logger.debug(f"network profile {args.network} already exists") except: # noqa: E722 logger.error( # noqa: TRY400 f"network profile {args.network} does not exists, are you sure you that you did specify a correct profile name?" ) sys.exit(1) profile_cpu = "cpu-" + str(args.cpu) + "-cores" profile_memory = f"mem-{args.memory}GiB" try: client.profiles.get(profile_cpu) logger.debug("profile " + profile_cpu + " already exists") except: # noqa: E722 logger.info("creating profile " + profile_cpu) profile_cpu_config = {"limits.cpu": str(args.cpu)} client.profiles.create(profile_cpu, config=profile_cpu_config) try: client.profiles.get(profile_memory) logger.debug("profile " + profile_memory + " already exists") except: # noqa: E722 logger.info("creating profile " + profile_memory) profile_memory_config = {"limits.memory": f"{args.memory}GiB"} client.profiles.create(profile_memory, config=profile_memory_config) profile_list = [args.network, profile_cpu, profile_memory] device_list = {"root": {"path": "/", "pool": args.pool, "type": "disk", "size": f"{args.quota}GiB"}} logger.debug(f"profile_list = profile_list") # noqa: F541 logger.debug('IT-8735: adding security.nesting: "true" on every containers') config = { "name": args.name, "source": { "type": "image", "alias": args.distrib, "mode": "pull", "server": "https://images.linuxcontainers.org", "protocol": "simplestreams", }, "profiles": profile_list, "devices": device_list, "config": {"security.nesting": "true"}, } logger.info("creating container " + args.name) container = client.instances.create(config, wait=True) logger.info("container created!") # IT-8895 - define LXC systemd override file location lxc_override_file = "/etc/systemd/system/service.d/lxc.conf" logger.info(f"removing {lxc_override_file} if it exists - see IT-8895") try: container.files.delete(lxc_override_file) logger.info(f"deleted {lxc_override_file}") except Exception as e: # noqa: F841 logger.info(f"{lxc_override_file} did not exist") # find the interface name for the network profile: profile = client.profiles.get(args.network) interface_name = list(profile.devices.keys())[0] # noqa: RUF015 if args.gateway is None: gateway = str(list(ip.network.hosts())[-1]) logger.debug("defining the gateway with the last IP of the network: " + gateway) else: gateway = args.gateway.compressed logger.debug("using manually defined gateway " + gateway) # create the network config if args.networkmethod == "networkd": network_config_path = "/etc/systemd/network/10-autogenerated.network" network_config_content = ( "[Match]\nName=" + interface_name + "\n[Network]\nLinkLocalAddressing=no\nAddress=" + str(ip) + "\nGateway=" + gateway + "\n" ) elif args.networkmethod == "interfaces": network_config_path = "/etc/network/interfaces" network_config_content = ( "# loopback\nauto lo\niface lo inet loopback\n\nauto " + interface_name + "\niface " + interface_name + " inet static\n\taddress " + str(ip.ip) + "\n\tnetmask " + str(ip.netmask) + "\n\tgateway " + gateway + "\n" ) logger.debug("network config:\n" + network_config_content) # send the network config container.files.put(network_config_path, network_config_content) # path and content of the apt configuration to not install the recommended packages by default no_recommends_path = "/etc/apt/apt.conf.d/no_recommends.conf" no_recommends_content = 'APT::Install-Recommends "false";\n' container.files.put(no_recommends_path, no_recommends_content) # if a proxy was defined, add the apt configuration for it if args.proxy != "no": apt_proxy_configuration_path = "/etc/apt/apt.conf.d/proxy.conf" apt_proxy_configuration_content = ( 'Acquire {\n HTTP::proxy "http://' + args.proxy + '";\n HTTPS::proxy "http://' + args.proxy + '";\n}\n' ) container.files.put(apt_proxy_configuration_path, apt_proxy_configuration_content) logger.info("starting the container...") container.start() wait(5) logger.info("container started") # execute all the needed chmod container.execute(["chmod", "644", network_config_path]) container.execute(["chmod", "644", no_recommends_path]) if args.proxy != "no": container.execute(["chmod", "644", apt_proxy_configuration_path]) # purge netplan.io logger.info("apt purge -y netplan.io") RESULT = container.execute(["apt-get", "purge", "-y", "netplan.io"]) logger.debug(str(RESULT)) wait(1) # stop and disable systemd-resolved logger.info("systemctl stop systemd-resolved.service") RESULT = container.execute(["systemctl", "stop", "systemd-resolved.service"]) logger.debug(str(RESULT)) wait(2) logger.info("systemctl disable systemd-resolved.service") RESULT = container.execute(["systemctl", "disable", "systemd-resolved.service"]) logger.debug(str(RESULT)) wait(2) # handle resolv.conf resolvconf_path = "/etc/resolv.conf" if args.dns is None: resolvconf_content = open(resolvconf_path, "r").read() # noqa: SIM115, PTH123, UP015 else: resolvconf_content = "# initial configuration\n" for dns in args.dns: resolvconf_content = resolvconf_content + "nameserver " + str(dns) + "\n" logger.info(f"unlink {resolvconf_path}") container.execute(["unlink", resolvconf_path]) logger.debug("resolvconf_content:\n" + resolvconf_content) logger.info(f"sending {resolvconf_path}") container.files.put(resolvconf_path, resolvconf_content) container.execute(["chmod", "644", resolvconf_path]) logger.info("waiting 3 seconds to allow the system to take into account the DNS change...") wait(3) if args.networkmethod == "networkd": # start and enable systemd-networkd logger.info("systemctl enable systemd-networkd") RESULT = container.execute(["systemctl", "enable", "systemd-networkd"]) logger.debug(str(RESULT)) logger.info("removing default config file /etc/systemd/network/eth0.network") container.execute(["unlink", "/etc/systemd/network/eth0.network"]) wait(1) logger.info("systemctl restart systemd-networkd") RESULT = container.execute(["systemctl", "restart", "systemd-networkd"]) logger.debug(str(RESULT)) wait(2) # handle authorized_keys logger.info("mkdir /root/.ssh") container.execute(["mkdir", "/root/.ssh"]) authorized_keys_path = "/root/.ssh/authorized_keys" authorized_keys_content = open(authorized_keys_path, "r").read() # noqa: SIM115, PTH123, UP015 logger.info("sending " + authorized_keys_path) container.files.put(authorized_keys_path, authorized_keys_content) # install openssh logger.info("apt update") RESULT = container.execute(["apt", "update"]) logger.debug(str(RESULT)) wait(3) logger.info("apt install -y openssh-server python3") RESULT = container.execute(["apt", "install", "-y", "openssh-server", "python3"]) logger.debug(str(RESULT)) # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4