diff --git a/common/vrnetlab.py b/common/vrnetlab.py index aa7426f0..7c5249da 100644 --- a/common/vrnetlab.py +++ b/common/vrnetlab.py @@ -111,9 +111,11 @@ def __init__( mgmt_dhcp=False, min_dp_nics=0, use_scrapli=False, - data_intf_prefix="eth" + data_intf_prefix="eth", + arch="x86_64", ): self.use_scrapli = use_scrapli + self.arch = arch # configure logging self.logger = logging.getLogger() @@ -305,13 +307,15 @@ def __init__( ] ) + machine = "pc" if self.arch == "x86_64" else "virt,virtualization=on -accel tcg,tb-size=128" + # Build qemu args self.qemu_args = [ - "qemu-system-x86_64", + f"qemu-system-{self.arch}", "-display", "none", "-machine", - "pc", + machine, f"-chardev socket,id=monitor0,host=::,port=40{self.num:02d},server=on,wait=off", "-monitor chardev:monitor0", f"-chardev socket,id=serial0,host=::,port=50{self.num:02d},server=on,wait=off,telnet=on", @@ -323,6 +327,9 @@ def __init__( "-smp", self.smp, # cpu core configuration ] + if self.arch == "aarch64": + # Use UEFI firmware for ARM64 + self.qemu_args.extend(["-drive", "if=pflash,unit=0,format=raw,file=/usr/share/AAVMF/AAVMF_CODE.fd,readonly=on"]) # Always use -drive to create the disk device - migration requires exact device match self.qemu_args.extend(["-drive", f"if={driveif},file={overlay_disk_image}"]) @@ -347,7 +354,7 @@ def start(self): self.logger.info("END ENVIRONMENT VARIABLES".center(60, "-")) self.logger.info( - f"Launching {self.__class__.__name__} with {self.smp} SMP/VCPU and {self.ram} M of RAM" + f"Launching {self.__class__.__name__} arch {self.arch} with {self.smp} SMP/VCPU and {self.ram} M of RAM" ) # give nice colours. Red if disabled, Green if enabled @@ -438,6 +445,9 @@ def start(self): self.scrapli_tn.open() else: self.tn = telnetlib.Telnet("127.0.0.1", 5000 + self.num) + # This enables super verbose telnetlib debugging + # if self.logger.isEnabledFor(logging.DEBUG): + # self.tn.set_debuglevel(2) break except: self.logger.error( diff --git a/makefile.include b/makefile.include index 288ddb78..78ae5d75 100644 --- a/makefile.include +++ b/makefile.include @@ -1,9 +1,17 @@ IMG_NAME=$(shell echo $(NAME) | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') IMG_VENDOR=$(shell echo $(VENDOR) | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') +IMG_REPOSITORY=$(REGISTRY)$(IMG_VENDOR)_$(IMG_NAME) IMAGES=$(shell ls $(IMAGE_GLOB) 2>/dev/null) NUM_IMAGES=$(shell ls $(IMAGES) | wc -l) VRNETLAB_VERION=$$(git log -1 --format=format:"Commit: %H from %aD") +# If DOCKER_PLATFORM is set, add --platform flag to docker build +ifdef DOCKER_PLATFORM +DOCKER_PLATFORM_ARG=--platform $(DOCKER_PLATFORM) +else +DOCKER_PLATFORM_ARG= +endif + ifeq ($(NUM_IMAGES), 0) docker-image: no-image usage else @@ -28,7 +36,7 @@ docker-build-image-copy: docker-build-common: docker-clean-build docker-pre-build @if [ -z "$$IMAGE" ]; then echo "ERROR: No IMAGE specified"; exit 1; fi @if [ "$(IMAGE)" = "$(VERSION)" ]; then echo "ERROR: Incorrect version string ($(IMAGE)). The regexp for extracting version information is likely incorrect, check the regexp in the Makefile or open an issue at https://github.com/hellt/vrnetlab/issues/new including the image file name you are using."; exit 1; fi - @echo "Building docker image using $(IMAGE) as $(REGISTRY)$(IMG_VENDOR)_$(IMG_NAME):$(VERSION)" + @echo "Building docker image using $(IMAGE) as $(IMG_REPOSITORY):$(VERSION)" ifeq ($(DONT_COPY_COMMON), 1) echo "Don't need to copy common assets" else @@ -42,19 +50,26 @@ else endif @[ -f ./vswitch.xml ] && cp vswitch.xml docker/ || true $(MAKE) IMAGE=$$IMAGE docker-build-image-copy - (cd docker; docker build --build-arg http_proxy=$(http_proxy) --build-arg HTTP_PROXY=$(HTTP_PROXY) --build-arg https_proxy=$(https_proxy) --build-arg HTTPS_PROXY=$(HTTPS_PROXY) --build-arg IMAGE=$(IMAGE) --build-arg VERSION=$(VERSION) --label "vrnetlab-version=$(VRNETLAB_VERION)" -t $(REGISTRY)$(IMG_VENDOR)_$(IMG_NAME):$(VERSION) .) + (cd docker; docker buildx build $(DOCKER_PLATFORM_ARG) --build-arg http_proxy=$(http_proxy) --build-arg HTTP_PROXY=$(HTTP_PROXY) --build-arg https_proxy=$(https_proxy) --build-arg HTTPS_PROXY=$(HTTPS_PROXY) --build-arg IMAGE=$(IMAGE) --build-arg VERSION=$(VERSION) --label "vrnetlab-version=$(VRNETLAB_VERION)" -t $(IMG_REPOSITORY):$(VERSION) .) -docker-build: docker-build-common docker-clean-build +# Override docker-build-extra in your Makefile to run additional commands after build +docker-build-extra: ; + +docker-build: docker-build-common docker-build-extra docker-clean-build docker-push: for IMAGE in $(IMAGES); do \ $(MAKE) IMAGE=$$IMAGE docker-push-image; \ done +# Override docker-push-image-extra in your Makefile to push additional tags +docker-push-image-extra: ; + docker-push-image: @if [ -z "$$IMAGE" ]; then echo "ERROR: No IMAGE specified"; exit 1; fi @if [ "$(IMAGE)" = "$(VERSION)" ]; then echo "ERROR: Incorrect version string"; exit 1; fi - docker push $(REGISTRY)$(IMG_VENDOR)_$(IMG_NAME):$(VERSION) + docker push $(IMG_REPOSITORY):$(VERSION) + $(MAKE) docker-push-image-extra usage: @echo "Usage: put the $(VENDOR) $(NAME) $(IMAGE_FORMAT) image in this directory and run:" diff --git a/mikrotik/routeros/Makefile b/mikrotik/routeros/Makefile index 35df5afd..6e2f5c98 100644 --- a/mikrotik/routeros/Makefile +++ b/mikrotik/routeros/Makefile @@ -1,11 +1,30 @@ VENDOR=Mikrotik NAME=RouterOS -IMAGE_FORMAT=vmdk -IMAGE_GLOB=*.vmdk +IMAGE_FORMAT=vmdk/vdi +IMAGE_GLOB=*.vmdk *.vdi # match versions like: -# chr-6.39.2.vmdk -VERSION=$(shell echo $(IMAGE) | sed -rn 's/.*chr-(.+)\.vmdk/\1/p') +# chr-6.39.2.vmdk -> 6.39.2 +# chr-7.20.4-arm64.vdi -> 7.20.4-arm64 +VERSION=$(shell echo $(IMAGE) | sed -rn 's/.*chr-(.+)\.(vmdk|vdi)/\1/p') + +# Detect target platform from image filename +# arm64 images have -arm64 in the filename, otherwise assume amd64 +DOCKER_PLATFORM=$(shell if echo "$(VERSION)" | grep -q '\-arm64'; then echo "linux/arm64"; else echo "linux/amd64"; fi) -include ../../makefile-sanity.include -include ../../makefile.include + +# Add -amd64 tag for amd64 images (for consistency with -arm64 tags) +# The base version tag is kept for backward compatibility +docker-build-extra: + @if ! echo "$(VERSION)" | grep -q '\-arm64'; then \ + echo "Adding -amd64 tag for $(IMG_REPOSITORY):$(VERSION)"; \ + docker tag $(IMG_REPOSITORY):$(VERSION) $(IMG_REPOSITORY):$(VERSION)-amd64; \ + fi + +docker-push-image-extra: + @if ! echo "$(VERSION)" | grep -q '\-arm64'; then \ + echo "Pushing -amd64 tag for $(IMG_REPOSITORY):$(VERSION)"; \ + docker push $(IMG_REPOSITORY):$(VERSION)-amd64; \ + fi \ No newline at end of file diff --git a/mikrotik/routeros/README.md b/mikrotik/routeros/README.md index 720a952b..921ef3c4 100644 --- a/mikrotik/routeros/README.md +++ b/mikrotik/routeros/README.md @@ -3,14 +3,23 @@ This is the vrnetlab docker image for Mikrotik RouterOS (ROS). ## Building the docker image -Download the Cloud Hosted Router VMDK image from https://www.mikrotik.com/download +Download the Cloud Hosted Router (CHR) VMDK or arm64 VDI image from https://www.mikrotik.com/download Copy the vmdk image into this folder, then run `make docker-image`. +### Cross platform builds + +It is possible to build amd64 images on ARM64 Macs, due to the built in Rosetta 2 emulation. + +For building arm64 images on amd64 machines please resort to: +https://docs.docker.com/build/building/multi-platform/#strategies + + Tested booting and responding to SSH: * chr-6.39.2.vmdk MD5:eb99636e3cdbd1ea79551170c68a9a27 * chr-6.47.9.vmdk * chr-7.1beta5.vmdk * chr-7.16.2.vmdk + * chr-7.20.4-arm64.vdi ## System requirements @@ -20,5 +29,7 @@ RAM: <1GB Disk: <1GB +On Apple ARM64 systems a CPU with nested virtualization support is needed. (M3 or greater) + ## Containerlab -Containerlab kind for routeros is [vr-ros](https://containerlab.srlinux.dev/manual/kinds/vr-ros/). +Containerlab kind for routeros is [mikrotik_ros](https://containerlab.dev/manual/kinds/vr-ros/). diff --git a/mikrotik/routeros/docker/Dockerfile b/mikrotik/routeros/docker/Dockerfile index c6ce44dd..9786d35d 100644 --- a/mikrotik/routeros/docker/Dockerfile +++ b/mikrotik/routeros/docker/Dockerfile @@ -1,6 +1,8 @@ -FROM public.ecr.aws/docker/library/debian:bookworm-slim +FROM public.ecr.aws/docker/library/debian:trixie-slim LABEL org.opencontainers.image.authors="roman@dodin.dev" +COPY --from=ghcr.io/astral-sh/uv:0.9.13 /uv /uvx /bin/ + ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update -qy \ @@ -12,6 +14,7 @@ RUN apt-get update -qy \ socat \ qemu-kvm \ qemu-system-x86 \ + qemu-efi-aarch64 \ tcpdump \ ssh \ inetutils-ping \ @@ -22,10 +25,14 @@ RUN apt-get update -qy \ ftp \ && rm -rf /var/lib/apt/lists/* +# use uv to install python 3.12. trixie ships with 3.13, which has no telnet package +RUN uv python install 3.12 +RUN uv python pin 3.12 + ARG IMAGE COPY $IMAGE* / COPY *.py / EXPOSE 22 161/udp 830 5000 5678 8291 10000-10099 -HEALTHCHECK CMD ["/healthcheck.py"] -ENTRYPOINT ["/launch.py"] +HEALTHCHECK CMD ["uv", "run", "/healthcheck.py"] +ENTRYPOINT ["uv", "run", "/launch.py"] diff --git a/mikrotik/routeros/docker/launch.py b/mikrotik/routeros/docker/launch.py index de58a827..1d7a369a 100755 --- a/mikrotik/routeros/docker/launch.py +++ b/mikrotik/routeros/docker/launch.py @@ -51,14 +51,29 @@ def trace(self, message, *args, **kws): class ROS_vm(vrnetlab.VM): def __init__(self, hostname, username, password, conn_mode): for e in os.listdir("/"): - if re.search(".vmdk$", e): + if re.search("(.vmdk|.vdi)$", e): disk_image = "/" + e + # determine architecture from disk image name + if re.search("-arm64", disk_image): + arch = "aarch64" + else: + arch = "x86_64" + + if arch == "aarch64": + ram_size = 512 # arm64 needs more ram + cpu_type = "cortex-a72" # seems to be faster than cortex-a710 + else: + ram_size = 256 + cpu_type = "qemu64" + # the default cpu=host only works when running clab on an amd64 machine - extra_args = {} if platform.machine() == "x86_64" else {"cpu": "qemu64"} + extra_args = {} if platform.machine() == "x86_64" else {"cpu": cpu_type} + + super(ROS_vm, self).__init__(username, password, disk_image=disk_image, ram=ram_size, driveif="virtio", arch=arch, **extra_args) + if self.arch != "aarch64": + self.qemu_args.extend(["-boot", "n"]) - super(ROS_vm, self).__init__(username, password, disk_image=disk_image, ram=256, **extra_args) - self.qemu_args.extend(["-boot", "n"]) self.hostname = hostname self.conn_mode = conn_mode self.nic_type = "virtio-net" # "e1000" is default but breaks mtu > 1500 on vlan subinterfaces on RouterOS 6.x.x @@ -89,7 +104,7 @@ def gen_mgmt(self): res.append("-device") res.append( - self.nic_type + ",netdev=br-mgmt,mac=%(mac)s" % {"mac": self.get_mgmt_mac()} + self.nic_type + ",romfile=,netdev=br-mgmt,mac=%(mac)s" % {"mac": self.get_mgmt_mac()} ) res.append("-netdev") res.append("bridge,br=br-mgmt,id=br-mgmt" % {"i": 0}) @@ -120,17 +135,22 @@ def bootstrap_spin(self): elif ridx == 1: self.wait_write("admin+ct", wait="RouterOS Login: ") self.wait_write("", wait="Password: ") - self.wait_write( - "n", wait="Do you want to see the software license? [Y/n]: " - ) + + # not happening on arm64 + if self.arch != "aarch64": + self.wait_write( + "n", wait="Do you want to see the software license? [Y/n]: " + ) # ROSv7 requires changing the password right away. ROSv6 does not require changing the password - (ridx2, match2, _) = self.tn.expect([b"new password>"], 1) + (ridx2, match2, _) = self.tn.expect([b"new password>"], 10) if match2 and ridx2 == 0: # got a match! login self.logger.debug("ROSv7 detected, setting admin password") self.wait_write(f"{self.password}", wait="new password>") self.wait_write(f"{self.password}", wait="repeat new password>") + changed = self.tn.read_until(b"Password changed", 10) + self.logger.debug(f"Got '{changed}' for password change response") self.logger.debug("Login completed")