diff --git a/c8000v/Makefile b/c8000v/Makefile new file mode 100644 index 00000000..b2c74b4a --- /dev/null +++ b/c8000v/Makefile @@ -0,0 +1,18 @@ +VENDOR=Cisco +NAME=c8000v +IMAGE_FORMAT=qcow2 +IMAGE_GLOB=*.qcow2 + +# match versions like: +# csr1000v-universalk9.16.03.01a.qcow2 +# csr1000v-universalk9.16.04.01.qcow2 +VERSION=$(shell echo $(IMAGE) | sed -e 's/.\+[^0-9]\([0-9]\+\.[0-9]\+\.[0-9]\+[a-z]\?\)\([^0-9].*\|$$\)/\1/') + +-include ../makefile-sanity.include +-include ../makefile.include +-include ../makefile-install.include + +docker-build: docker-build-common + docker run --cidfile cidfile --privileged $(REGISTRY)vr-$(VR_NAME):$(VERSION) --trace --install + docker commit --change='ENTRYPOINT ["/launch.py"]' $$(cat cidfile) $(REGISTRY)vr-$(VR_NAME):$(VERSION) + docker rm -f $$(cat cidfile) diff --git a/c8000v/README.md b/c8000v/README.md new file mode 100644 index 00000000..78a2b25a --- /dev/null +++ b/c8000v/README.md @@ -0,0 +1,106 @@ +# Cisco Catalyst 8000V Edge Software + +This is the vrnetlab docker image for Cisco Catalyst 8000V Edge Software, or +'c8000v' for short. + +The Catalyst 8000v platform is a successor to the CSR 1000v. As such, this +platform directory 'c8000v' started off as a copy of the 'csr' directory. With +time we imagine the two platforms will diverge. One such change is already +planned to support using the Catalyst 8000v in one of the two modes: + +- regular, +- SD-WAN Controller mode (managed by Viptela). + +Right now the SD-WAN flavor is still split off because to enable the Controller +mode you have to effectively boot the router into a completely different mode. +In the near future these modifications will be merged back into the 'c8000v' +platform that will produce both the regular and sd-wan images. + +On installation of Catalyst 8000v the user is presented with the choice of +output, which can be over serial console, a video console or through automatic +detection of one or the other. Empirical studies show that the automatic +detection is far from infallible and so we force the use of the serial console +by feeding the VM an .iso image that contains a small bootstrap configuration +that sets the output to serial console. This means we have to boot up the VM +once to feed it this configuration and then restart it for the changes to take +effect. Naturally we want to do this in the build process as to avoid having to +restart the router once for every time we run the docker image. Unfortunately +docker doesn't allow us to run docker build with `--privileged` so there is no +KVM acceleration making this process excruciatingly slow were it to be performed +in the docker build phase. Instead we build a basic image using docker build, +which essentially just assembles the required files, then run it with +`--privileged` to start up the VM and feed it the .iso image. After we are done +we shut down the VM and commit this new state into the final docker image. This +is unorthodox but works and saves us a lot of time. + +## Building the docker image + +Put the .qcow2 file in this directory and run `make docker-image` and you should +be good to go. The resulting image is called `vr-c8000v`. You can tag it with +something else if you want, like `my-repo.example.com/vr-c8000v` and then push +it to your repo. The tag is the same as the version of the Catalyst 8000v image, +so if you have c8000v-universalk9.16.04.01.qcow2 your final docker image will be +called `vr-c8000v:16.04.01` + +Please note that you will always need to specify version when starting your +router as the "latest" tag is not added to any images since it has no meaning +in this context. + +It's been tested to boot and respond to SSH with: + +- 16.03.01a (c8000v-universalk9.16.03.01a.qcow2) +- 16.04.01 (c8000v-universalk9.16.04.01.qcow2) + +## Usage + +```bash +docker run -d --privileged --name my-c8000v-router vr-c8000v +``` + +## Interface mapping + +IOS XE 16.03.01 and 16.04.01 does only support 10 interfaces, GigabitEthernet1 is always configured +as a management interface and then we can only use 9 interfaces for traffic. If you configure vrnetlab +to use more then 10 the interfaces will be mapped like the table below. + +The following images have been verified to NOT exhibit this behavior + +- c8000v-universalk9.03.16.02.S.155-3.S2-ext.qcow2 +- c8000v-universalk9.03.17.02.S.156-1.S2-std.qcow2 + +| vr-c8000v | vr-xcon | +| :-------: | :-----: | +| Gi2 | 10 | +| Gi3 | 1 | +| Gi4 | 2 | +| Gi5 | 3 | +| Gi6 | 4 | +| Gi7 | 5 | +| Gi8 | 6 | +| Gi9 | 7 | +| Gi10 | 8 | +| Gi11 | 9 | + +## System requirements + +CPU: 1 core + +RAM: 4GB + +Disk: <500MB + +## License handling + +You can feed a license file into c8000v by putting a text file containing the +license in this directory next to your .qcow2 image. Name the license file the +same as your .qcow2 file but append ".license", e.g. if you have +"c8000v-universalk9.16.04.01.qcow2" you would name the license file +"c8000v-universalk9.16.04.01.qcow2.license". + +The license is bound to a specific UDI and usually expires within a given time. +To make sure that everything works out smoothly we configure the clock to +a specific date during the installation process. This is because the license +only has an expiration date not a start date. + +The license unlocks feature and throughput. The default throughput for C8000v is +20Mbit/s which is perfectly for basic management and testing. diff --git a/c8000v/docker/Dockerfile b/c8000v/docker/Dockerfile new file mode 100644 index 00000000..412c0f5b --- /dev/null +++ b/c8000v/docker/Dockerfile @@ -0,0 +1,29 @@ +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update -qy \ + && apt-get upgrade -qy \ + && apt-get install -y \ + bridge-utils \ + iproute2 \ + python3-ipy \ + socat \ + qemu-kvm \ + tcpdump \ + inetutils-ping \ + ssh \ + telnet \ + procps \ + genisoimage \ + && rm -rf /var/lib/apt/lists/* + +ARG VERSION +ENV VERSION=${VERSION} +ARG IMAGE +COPY $IMAGE* / +COPY *.py / + +EXPOSE 22 161/udp 830 5000 10000-10099 +HEALTHCHECK CMD ["/healthcheck.py"] +ENTRYPOINT ["/launch.py"] diff --git a/c8000v/docker/launch.py b/c8000v/docker/launch.py new file mode 100755 index 00000000..f274a9f4 --- /dev/null +++ b/c8000v/docker/launch.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 + +import datetime +import logging +import os +import re +import signal +import subprocess +import sys + +import vrnetlab + +STARTUP_CONFIG_FILE = "/config/startup-config.cfg" + + +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 C8000v_vm(vrnetlab.VM): + def __init__(self, hostname, username, password, conn_mode, install_mode=False): + disk_image = None + for e in sorted(os.listdir("/")): + if not disk_image and re.search(".qcow2$", e): + disk_image = "/" + e + if re.search(r"\.license$", e): + os.rename("/" + e, "/tftpboot/license.lic") + + self.license = False + if os.path.isfile("/tftpboot/license.lic"): + logger.info("License found") + self.license = True + + super(C8000v_vm, self).__init__(username, password, disk_image=disk_image) + self.nic_type = "vmxnet3" + self.install_mode = install_mode + self.hostname = hostname + self.conn_mode = conn_mode + self.num_nics = 9 + + if self.install_mode: + logger.trace("install mode") + self.image_name = "config.iso" + self.create_boot_image() + + self.qemu_args.extend(["-cdrom", "/" + self.image_name]) + + def create_boot_image(self): + """Creates a iso image with a bootstrap configuration""" + + with open("/iosxe_config.txt", "w") as cfg_file: + if self.license: + cfg_file.write("do clock set 13:33:37 1 Jan 2010\r\n") + cfg_file.write("interface GigabitEthernet1\r\n") + cfg_file.write("ip address 10.0.0.15 255.255.255.0\r\n") + cfg_file.write("no shut\r\n") + cfg_file.write("exit\r\n") + cfg_file.write("license accept end user agreement\r\n") + cfg_file.write("yes\r\n") + cfg_file.write("do license install tftp://10.0.0.2/license.lic\r\n\r\n") + cfg_file.write("license boot level network-premier addon dna-premier\r\n") + cfg_file.write("platform console serial\r\n\r\n") + cfg_file.write("do clear platform software vnic-if nvtable\r\n") + cfg_file.write("do wr\r\n") + cfg_file.write("do reload\r\n") + + genisoimage_args = [ + "genisoimage", + "-l", + "-o", + "/" + self.image_name, + "/iosxe_config.txt", + ] + + subprocess.Popen(genisoimage_args) + + def bootstrap_spin(self): + """This function should be called periodically to do work.""" + + if self.spins > 300: + # too many spins with no result -> give up + self.stop() + self.start() + return + + (ridx, match, res) = self.tn.expect( + [b"Press RETURN to get started!", b"IOSXEBOOT-4-FACTORY_RESET"], 1 + ) + if match: # got a match! + if ridx == 0: # login + self.logger.debug("matched, Press RETURN to get started.") + if self.install_mode: + self.logger.debug("Now we wait for the device to reload") + else: + self.wait_write("", wait=None) + + # run main config! + self.bootstrap_config() + # add startup config if present + self.startup_config() + # 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) + # mark as running + self.running = True + return + elif ridx == 1: # IOSXEBOOT-4-FACTORY_RESET + if self.install_mode: + install_time = datetime.datetime.now() - self.start_time + self.logger.info("Install complete in: %s", install_time) + self.running = True + return + else: + self.log.warning("Unexpected reload while running") + + # 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 bootstrap_config(self): + """Do the actual bootstrap config""" + self.logger.info("applying bootstrap configuration") + + self.wait_write("", None) + self.wait_write("enable", wait=">") + self.wait_write("configure terminal", wait=">") + + self.wait_write(f"hostname {self.hostname}") + self.wait_write( + "username %s privilege 15 password %s" % (self.username, self.password) + ) + if int(self.version.split(".")[0]) >= 16: + self.wait_write("ip domain name example.com") + else: + self.wait_write("ip domain-name example.com") + self.wait_write("crypto key generate rsa modulus 2048") + + self.wait_write("interface GigabitEthernet1") + self.wait_write("ip address 10.0.0.15 255.255.255.0") + self.wait_write("no shut") + self.wait_write("exit") + self.wait_write("restconf") + self.wait_write("netconf-yang") + self.wait_write("netconf max-sessions 16") + # I did not find any documentation about this, but is seems like a good idea!? + self.wait_write("netconf detailed-error") + self.wait_write("ip ssh server algorithm mac hmac-sha2-512") + self.wait_write("ip ssh maxstartups 128") + + self.wait_write("line vty 0 4") + self.wait_write("login local") + self.wait_write("transport input all") + self.wait_write("end") + self.wait_write("copy running-config startup-config") + self.wait_write("\r", "Destination") + + def startup_config(self): + """Load additional config provided by user.""" + + if not os.path.exists(STARTUP_CONFIG_FILE): + self.logger.trace(f"Startup config file {STARTUP_CONFIG_FILE} is not found") + return + + self.logger.trace(f"Startup config file {STARTUP_CONFIG_FILE} exists") + with open(STARTUP_CONFIG_FILE) as file: + config_lines = file.readlines() + config_lines = [line.rstrip() for line in config_lines] + self.logger.trace(f"Parsed startup config file {STARTUP_CONFIG_FILE}") + + self.logger.info(f"Writing lines from {STARTUP_CONFIG_FILE}") + + self.wait_write("configure terminal") + # Apply lines from file + for line in config_lines: + self.wait_write(line) + # End and Save + self.wait_write("end") + self.wait_write("copy running-config startup-config") + self.wait_write("\r", "Destination") + + +class C8000v(vrnetlab.VR): + def __init__(self, hostname, username, password, conn_mode): + super(C8000v, self).__init__(username, password) + self.vms = [C8000v_vm(hostname, username, password, conn_mode)] + + +class C8000v_installer(C8000v): + """C8000v installer + + Will start the C8000v with a mounted iso to make sure that we get + console output on serial, not vga. + """ + + def __init__(self, hostname, username, password, conn_mode): + super(C8000v_installer, self).__init__(hostname, username, password, conn_mode) + self.vms = [ + C8000v_vm(hostname, username, password, conn_mode, install_mode=True) + ] + + def install(self): + self.logger.info("Installing C8000v") + csr = self.vms[0] + while not csr.running: + csr.work() + csr.stop() + self.logger.info("Installation complete") + + +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="vrnetlab", help="Username") + parser.add_argument("--password", default="VR-netlab9", help="Password") + parser.add_argument("--install", action="store_true", help="Install C8000v") + parser.add_argument("--hostname", default="c8000v", help="Router hostname") + parser.add_argument( + "--connection-mode", + default="vrxcon", + 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) + + if args.install: + vr = C8000v_installer( + args.hostname, args.username, args.password, args.connection_mode + ) + vr.install() + else: + vr = C8000v(args.hostname, args.username, args.password, args.connection_mode) + vr.start() diff --git a/common/vrnetlab.py b/common/vrnetlab.py index 730ea2aa..9f35e654 100644 --- a/common/vrnetlab.py +++ b/common/vrnetlab.py @@ -707,6 +707,19 @@ def check_qemu(self): self.start() + @property + def version(self): + """Read version number from VERSION environment variable + + The VERSION environment variable is set at build time using the value + from the makefile. If the environment variable is not defined please add + the variables in the Dockerfile (see csr)""" + version = os.environ.get("VERSION") + if version is not None: + return version + raise ValueError("The VERSION environment variable is not set") + + class VR: def __init__(self, username, password): self.logger = logging.getLogger() diff --git a/csr/docker/Dockerfile b/csr/docker/Dockerfile index 683199d2..7606384d 100644 --- a/csr/docker/Dockerfile +++ b/csr/docker/Dockerfile @@ -20,6 +20,8 @@ RUN apt-get update -qy \ genisoimage \ && rm -rf /var/lib/apt/lists/* +ARG VERSION +ENV VERSION=${VERSION} ARG IMAGE COPY $IMAGE* / COPY *.py / diff --git a/csr/docker/launch.py b/csr/docker/launch.py index abe017f7..aac0e4f9 100755 --- a/csr/docker/launch.py +++ b/csr/docker/launch.py @@ -152,7 +152,10 @@ def bootstrap_config(self): self.wait_write( "username %s privilege 15 password %s" % (self.username, self.password) ) - self.wait_write("ip domain name example.com") + if int(self.version.split('.')[0]) >= 16: + self.wait_write("ip domain name example.com") + else: + self.wait_write("ip domain-name example.com") self.wait_write("crypto key generate rsa modulus 2048") self.wait_write("interface GigabitEthernet1") diff --git a/makefile.include b/makefile.include index ecb5a3f4..7e21929f 100644 --- a/makefile.include +++ b/makefile.include @@ -26,7 +26,7 @@ docker-build-common: docker-clean-build docker-pre-build @echo "Building docker image using $(IMAGE) as $(REGISTRY)vr-$(VR_NAME):$(VERSION)" cp ../common/* docker/ $(MAKE) IMAGE=$$IMAGE docker-build-image-copy - (cd docker; docker build --build-arg http_proxy=$(http_proxy) --build-arg https_proxy=$(https_proxy) --build-arg IMAGE=$(IMAGE) -t $(REGISTRY)vr-$(VR_NAME):$(VERSION) .) + (cd docker; docker build --build-arg http_proxy=$(http_proxy) --build-arg https_proxy=$(https_proxy) --build-arg IMAGE=$(IMAGE) --build-arg VERSION=$(VERSION) -t $(REGISTRY)vr-$(VR_NAME):$(VERSION) .) docker-build: docker-build-common