From 358144e96287582dbe2bd8e7662a614dbda7c7db Mon Sep 17 00:00:00 2001 From: Gusman Dharma P Date: Sun, 30 Jun 2024 14:37:47 +0700 Subject: [PATCH] Initial support for Nokia Mag-C CMG Linux. (#216) * Initial support for Nokia Mag-C CMG Linux. CMG Linux is used as a DB VM which is a part of Nokia Mag-C VMs. * Fix several error or warnings from DeepSource * formatting and no install recommends * added yaml dep --------- Co-authored-by: Roman Dodin --- cmglinux/Makefile | 12 ++ cmglinux/README.md | 100 +++++++++++++ cmglinux/docker/Dockerfile | 32 ++++ cmglinux/docker/launch.py | 299 +++++++++++++++++++++++++++++++++++++ 4 files changed, 443 insertions(+) create mode 100644 cmglinux/Makefile create mode 100644 cmglinux/README.md create mode 100644 cmglinux/docker/Dockerfile create mode 100755 cmglinux/docker/launch.py diff --git a/cmglinux/Makefile b/cmglinux/Makefile new file mode 100644 index 00000000..2397b02e --- /dev/null +++ b/cmglinux/Makefile @@ -0,0 +1,12 @@ +VENDOR=Canonical +NAME=Ubuntu +IMAGE_FORMAT=qcow2 +IMAGE_GLOB=*.qcow2 + +# match versions like: +# cmg-linux-24.3.R1.qcow2 +VERSION=$(shell echo $(IMAGE) | sed -e 's/.\+[^0-9]\([0-9]\+\.[0-9]\+\.[A-Z][0-9]\+\(-[0-9]\+\)\?\)[^0-9].*$$/\1/') + + +-include ../makefile-sanity.include +-include ../makefile.include diff --git a/cmglinux/README.md b/cmglinux/README.md new file mode 100644 index 00000000..b172592f --- /dev/null +++ b/cmglinux/README.md @@ -0,0 +1,100 @@ +# CMG Linux VM + +## Introduction + +CMG Linux is utilized as a DB VM as one of Nokia MAG-C VMs. +The image of CMG Linux is released in qcow2 format. +Since some of MAG-C tools in CMG Linux are written with systemd or systemctl utilization, +then CMG Linux cannot be containerized because docker does not run systemd inside the container. +Given this reasoning, the approach to containerized CMG Linux is to create a container +to run a CMG Linux VM in the same way Vrnetlab has done. + +## Build the docker image + +It is required to provide CMG Linux qcow2 image to build the docker image. +Nokia representative can provide the qcow2 file. + +Make sure that your python virtualenv has `yaml` package installed. + +Copy the `cmg-linux.qcow2` file in `vrnetlab/cmglinux` directory +and rename the file by appending the version to it. +For example, for CMG Linux version 24.3.r1, +make sure that the qcow2 file will be named as `cmg-linux-24.3.R1.qcow2`. +The version 24.3.R1 will be used as a container image tag. + +Run `make docker-image` to start the build process. +The resulting image is called `vrnetlab/cmglinux:`. +You can tag it with something else. for example, `cmglinux:`. + +## Host requirements + +* 4 vCPU +* 6 GB RAM + +## Configuration + +Initial config is carried out via cloud-init. +By default CMG-Linux boots by using a pre-defined cloud-init config drive. + +Custom configuration can be added by binding the local `config_drive` +directory to `/config_drive` directory in the container. +The accepted structure of `config_drive`is shown below. +Any other directories or files not specified below are ignored. + +``` text +config_drive/ +└── openstack/ + ├── latest/ + │ ├── meta_data.json + │ └── user_data + └── content/ + ├── 0000 (referenced content files) + ├── 0001 + └── .... +``` + +The internal `launch.py` script also modifies the content of `user_data` to add `clab`as +default user with password `clab@123`. Moreover, it also modifies `user_data` +to configure the management network interface. + +Also `9.9.9.9` configured as the DNS resolver. Change it with `resolvectl` if required. + +## Example containerlab topology + +Below is an example of Containerlab topology using CMG Linux. + +``` yaml +name: test_cmglinux +prefix: __lab-name +topology: + nodes: + cmg-1: + kind: generic_vm + image: vrnetlab/vr-cmglinux:24.3.R3 + binds: + - config_drive_cmg1:/config_drive + cmg-2: + kind: generic_vm + image: vrnetlab/vr-cmglinux:24.3.R1 + binds: + - config_drive_cmg2:/config_drive + alpine: + kind: linux + image: alpine:dev + links: + - endpoints: + - cmg-1:eth1 + - alpine:eth1 + - endpoints: + - cmg-1:eth2 + - alpine:eth2 + - endpoints: + - cmg-1:eth3 + - alpine:eth3 + - endpoints: + - cmg-2:eth1 + - alpine:eth4 + - endpoints: + - cmg-2:eth2 + - alpine:eth5 +``` diff --git a/cmglinux/docker/Dockerfile b/cmglinux/docker/Dockerfile new file mode 100644 index 00000000..103cb747 --- /dev/null +++ b/cmglinux/docker/Dockerfile @@ -0,0 +1,32 @@ +FROM public.ecr.aws/docker/library/debian:bookworm-slim + +ARG DEBIAN_FRONTEND=noninteractive +ARG DISK_SIZE=4G + +RUN apt-get update -qy \ + && apt-get install -y --no-install-recommends \ + bridge-utils \ + iproute2 \ + socat \ + qemu-kvm \ + tcpdump \ + ssh \ + inetutils-ping \ + dnsutils \ + iptables \ + nftables \ + telnet \ + genisoimage \ + python3-yaml \ + sshpass \ + && rm -rf /var/lib/apt/lists/* + +ARG IMAGE +COPY $IMAGE* / +COPY *.py / + +# RUN qemu-img resize /${IMAGE} ${DISK_SIZE} + +EXPOSE 22 5000 10000-10099 +HEALTHCHECK CMD ["/healthcheck.py"] +ENTRYPOINT ["/launch.py"] diff --git a/cmglinux/docker/launch.py b/cmglinux/docker/launch.py new file mode 100755 index 00000000..a7a17d82 --- /dev/null +++ b/cmglinux/docker/launch.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 + +import datetime +import json +import logging +import os +import re +import shlex +import shutil +import signal +import subprocess +import sys + +import vrnetlab +import yaml + +DEFAULT_IFCFG_ETH0 = """ +DEVICE=eth0 +BOOTPROTO=none +ONBOOT=yes +NETMASK=255.255.255.0 +IPADDR=10.0.0.15 +GATEWAY=10.0.0.2 +DNS1=9.9.9.9 +USERCTL=yes +""" + +CONFIG_DRIVE = os.path.join("/", "config_drive") +ISO_DRIVE = os.path.join("/", "iso_drive") + + +def handle_SIGCHLD(signal, frame): + os.waitpid(-1, os.WNOHANG) + + +def handle_SIGTERM(signal, frame): + sys.exit(0) + + +signal.signal(signal.SIGINT, handle_SIGTERM) +signal.signal(signal.SIGTERM, handle_SIGTERM) +signal.signal(signal.SIGCHLD, handle_SIGCHLD) + +TRACE_LEVEL_NUM = 9 +logging.addLevelName(TRACE_LEVEL_NUM, "TRACE") + + +def trace(self, message, *args, **kws): + # Yes, logger takes its '*args' as 'args'. + if self.isEnabledFor(TRACE_LEVEL_NUM): + self._log(TRACE_LEVEL_NUM, message, args, **kws) + + +logging.Logger.trace = trace + + +class CmgLinux_vm(vrnetlab.VM): + def __init__( + self, + hostname, + username, + password, + nics, + conn_mode, + ): + for e in os.listdir("/"): + if re.search(".qcow2$", e): + disk_image = "/" + e + + super(CmgLinux_vm, self).__init__( + username, password, disk_image=disk_image, ram=6144, smp="4" + ) + + self.num_nics = nics + self.hostname = hostname + self.conn_mode = conn_mode + self.nic_type = "virtio-net-pci" + + self.cfg_drive_iso_path = os.path.join("/", "iso-drive.iso") + self.create_config_drive_image() + + self.qemu_args.extend(["-cdrom", self.cfg_drive_iso_path]) + + if "ADD_DISK" in os.environ: + disk_size = os.getenv("ADD_DISK") + + self.add_disk(disk_size) + + def _update_user_data(self, user_data: dict) -> dict: + if "users" not in user_data: + user_data["users"] = [] + + clab_user = [u for u in user_data["users"] if u.get("name") == self.username] + # Just update if the user not exist + # If exists the the user may have another intention + # to configure the user + if not clab_user: + user_data["users"].append( + { + "name": self.username, + "plain_text_passwd": self.password, + "lock_passwd": False, + "sudo": "ALL=(ALL) NOPASSWD:ALL", + } + ) + + if "write_files" not in user_data: + user_data["write_files"] = [] + + ifcfg = [ + p + for p in user_data["write_files"] + if p.get("path") == "/etc/sysconfig/network-scripts/ifcfg-eth0" + ] + if not ifcfg: + user_data["write_files"].append( + { + "path": "/etc/sysconfig/network-scripts/ifcfg-eth0", + "content": DEFAULT_IFCFG_ETH0, + } + ) + else: + ifcfg_eth0 = ifcfg[0] + ifcfg_eth0["content"] = DEFAULT_IFCFG_ETH0 + + return user_data + + @staticmethod + def _update_meta_data(meta_data: dict) -> dict: + if "uuid" not in meta_data: + meta_data["uuid"] = "00000000-0000-0000-0000-000000000000" + + node_name = os.environ.get("CLAB_LABEL_CLAB_NODE_NAME", "cmg-linux") + meta_data["hostname"] = node_name + meta_data["name"] = node_name + + return meta_data + + def create_config_drive_image(self): + iso_latest_dir = os.path.join(ISO_DRIVE, "openstack", "latest") + user_data_path = os.path.join(iso_latest_dir, "user_data") + meta_data_path = os.path.join(iso_latest_dir, "meta_data.json") + + if not os.path.isdir(iso_latest_dir): + os.makedirs(iso_latest_dir, exist_ok=True) + + config_dir_latest = os.path.join(CONFIG_DRIVE, "openstack", "latest") + if os.path.isdir(config_dir_latest): + shutil.copytree(config_dir_latest, iso_latest_dir, dirs_exist_ok=True) + + config_dir_content = os.path.join(CONFIG_DRIVE, "openstack", "content") + if os.path.isdir(config_dir_content): + shutil.copytree( + config_dir_content, + os.path.join(ISO_DRIVE, "openstack", "content"), + dirs_exist_ok=True, + ) + + # Update user_data + user_data = {} + if os.path.isfile(user_data_path): + with open(user_data_path, "r") as f: + user_data = yaml.safe_load(f) + else: + user_data = { + "users": [], + "write_files": [], + "final_message": "Vrnetlab cloud init done", + } + + # Dump updated user_data to the original file + user_data = self._update_user_data(user_data) + with open(user_data_path, "w") as f: + ctx_str = yaml.safe_dump(user_data, sort_keys=False, indent=2) + ctx_str = f"#cloud-config\n{ctx_str}" + f.write(ctx_str) + + # Update meta_data.json + meta_data = {} + if os.path.isfile(meta_data_path): + with open(meta_data_path, "r") as f: + meta_data = yaml.safe_load(f) + + # Dump updated meta_data to the original file + meta_data = self._update_meta_data(meta_data) + with open(meta_data_path, "w") as f: + json.dump(meta_data, f, indent=2) + + # Create seeds.iso or config_drive.iso + cmd_args = shlex.split( + f"mkisofs -J -l -R -V config-2 -iso-level 4 -o {self.cfg_drive_iso_path} /iso_drive" + ) + subprocess.Popen(cmd_args) + + def bootstrap_spin(self): + """This function should be called periodically to do work.""" + + if self.spins > 6000: + # too many spins with no result -> give up + self.logger.debug("Too many spins -> give up") + self.stop() + self.start() + return + + (ridx, match, res) = self.tn.expect([b"login: "], 1) + # got am match and login + if match and ridx == 0: + self.logger.debug("matched, login: ") + self.wait_write("", wait=None) + + self.running = True + # close telnet connection + self.tn.close() + # startup time? + startup_time = datetime.datetime.now() - self.start_time + self.logger.info("Startup complete in: %s", startup_time) + return + + # no match, if we saw some output from the router it's probably + # booting, so let's give it some more time + if res != b"": + self.logger.trace("OUTPUT: %s" % res.decode()) + # reset spins if we saw some output + self.spins = 0 + + self.spins += 1 + + return + + def gen_mgmt(self): + """ + Augment the parent class function to change the PCI bus + """ + # call parent function to generate the mgmt interface + res = super(CmgLinux_vm, self).gen_mgmt() + + # we need to place mgmt interface on the same bus with other interfaces in Ubuntu, + # to get nice (predictable) interface names + if "bus=pci.1" not in res[-3]: + res[-3] = res[-3] + ",bus=pci.1" + return res + + def add_disk(self, disk_size, driveif="ide"): + additional_disk = f"disk_{disk_size}.qcow2" + + if not os.path.exists(additional_disk): + self.logger.debug(f"Creating additional disk image {additional_disk}") + vrnetlab.run_command( + ["qemu-img", "create", "-f", "qcow2", additional_disk, disk_size] + ) + + self.qemu_args.extend( + [ + "-drive", + f"if={driveif},file={additional_disk}", + ] + ) + + +class CmgLinux(vrnetlab.VR): + def __init__(self, hostname, username, password, nics, conn_mode): + super(CmgLinux, self).__init__(username, password) + self.vms = [CmgLinux_vm(hostname, username, password, nics, conn_mode)] + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="") + parser.add_argument( + "--trace", action="store_true", help="enable trace level logging" + ) + parser.add_argument("--username", default="sysadmin", help="Username") + parser.add_argument("--password", default="sysadmin", help="Password") + parser.add_argument("--hostname", default="ubuntu", help="VM Hostname") + parser.add_argument("--nics", type=int, default=16, help="Number of NICS") + parser.add_argument( + "--connection-mode", + default="tc", + help="Connection mode to use in the datapath", + ) + args = parser.parse_args() + + LOG_FORMAT = "%(asctime)s: %(module)-10s %(levelname)-8s %(message)s" + logging.basicConfig(format=LOG_FORMAT) + logger = logging.getLogger() + + logger.setLevel(logging.DEBUG) + if args.trace: + logger.setLevel(1) + + vr = CmgLinux( + args.hostname, + args.username, + args.password, + args.nics, + args.connection_mode, + ) + vr.start()