diff --git a/bindep.txt b/bindep.txt index 13f7a9dcae..6e1e3e68e9 100644 --- a/bindep.txt +++ b/bindep.txt @@ -99,4 +99,5 @@ zstd [devstack] # For graphical console support podman [devstack] -systemd-container [devstack] \ No newline at end of file +systemd-container [devstack] +buildah [devstack] diff --git a/devstack/lib/ironic b/devstack/lib/ironic index 1356226f32..bec1cf6606 100644 --- a/devstack/lib/ironic +++ b/devstack/lib/ironic @@ -1279,7 +1279,7 @@ function install_ironic { fi if is_service_enabled ir-novnc; then - # a websockets/html5 or flash powered VNC console for vm instances + # a websockets/html5 VNC console for bare metal hosts NOVNC_FROM_PACKAGE=$(trueorfalse False NOVNC_FROM_PACKAGE) if [ "$NOVNC_FROM_PACKAGE" = "True" ]; then # Installing novnc on Debian bullseye breaks the global pip @@ -1304,7 +1304,11 @@ function install_ironic { git_clone $NOVNC_REPO $NOVNC_WEB_DIR $NOVNC_BRANCH fi # podman, systemd-container required by the systemd container provider - install_package podman systemd-container + # buildah required below to build the VNC container + install_package podman systemd-container buildah + pushd $IRONIC_DIR/tools/vnc-container + buildah bud -f ./Containerfile -t localhost/ironic-vnc-container + popd fi } @@ -2057,8 +2061,7 @@ function configure_ironic_novnc { iniset $IRONIC_CONF_FILE vnc port $service_port iniset $IRONIC_CONF_FILE vnc novnc_web $NOVNC_WEB_DIR iniset $IRONIC_CONF_FILE vnc container_provider systemd - # TODO(stevebaker) build this locally during the devstack run - # iniset $IRONIC_CONF_FILE vnc console_image localhost/ironic-vnc-container + iniset $IRONIC_CONF_FILE vnc console_image localhost/ironic-vnc-container } diff --git a/setup.cfg b/setup.cfg index e04776d171..6e8a192367 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,7 @@ data_files = etc/ironic = etc/ironic/rootwrap.conf etc/ironic/rootwrap.d = etc/ironic/rootwrap.d/* + share/ironic/vnc-container = tools/vnc-container/* packages = ironic diff --git a/tools/vnc-container/Containerfile b/tools/vnc-container/Containerfile new file mode 100644 index 0000000000..38c01b7fb9 --- /dev/null +++ b/tools/vnc-container/Containerfile @@ -0,0 +1,25 @@ +FROM quay.io/centos/centos:stream9 + +RUN dnf -y install \ + epel-release && \ + dnf -y install \ + chromium \ + chromedriver \ + dumb-init \ + procps \ + psmisc \ + python3-requests \ + python3-selenium \ + x11vnc \ + xorg-x11-server-Xvfb + +ENV DISPLAY_WIDTH=1280 +ENV DISPLAY_HEIGHT=960 + +ENV APP='fake' + +ADD bin/* /usr/local/bin +ADD drivers /drivers + +ENTRYPOINT ["/usr/bin/dumb-init", "--"] +CMD ["/usr/local/bin/start-xvfb.sh"] \ No newline at end of file diff --git a/tools/vnc-container/README.rst b/tools/vnc-container/README.rst new file mode 100644 index 0000000000..57bca6722d --- /dev/null +++ b/tools/vnc-container/README.rst @@ -0,0 +1,74 @@ +============= +VNC Container +============= + +Overview +-------- + +This allows a container image to be built which supports Ironic's graphical +console functionality. + +For each node with an enabled graphical console, the service ironic-novncproxy +(or nova-novncproxy) will connect to a VNC server exposed by a container +running this image. + +Building and using +------------------ + +To build the container image for local use, install ``buildah`` and run the +following as the user which runs ironic-conductor:: + + buildah bud -f ./Containerfile -t localhost/ironic-vnc-container + +The ``systemd`` container provider (or an external provider) can then be configured +to use this image in ``ironic.conf``: + +.. code-block:: ini + + [vnc] + container_provider=systemd + console_image=localhost/ironic-vnc-container + + +Implementation +-------------- + +When the container is started the following occurs: + +1. Xvfb is run, which starts a virtual X11 session +2. x11vnc is run, which exposes a VNC server port + +When a VNC connection is established a Selenium python script is started +which: + +1. Starts a Chromium browser +2. For the ``fake`` app displays drivers/fake/index.html +3. For the ``redfish`` app detects the vendor by looking at the ``Oem`` + value in a ``/redfish/v1`` response +4. Runs vendor specific code to display an HTML5 based console + +When the VNC connection is terminated, the Selenium script and Chromium is +also terminated. + +Vendor specific implementations are as follows. + +Dell iDRAC +~~~~~~~~~~ + +One-time console credentials are created with a call to +``/Managers/<manager>/Oem/Dell/DelliDRACCardService/Actions/DelliDRACCardService.GetKVMSession`` +and the browser loads a console URL using those credentials. + +HPE iLO +~~~~~~~ + +The ``/irc.html`` URL is loaded. For iLO 6 the inline login form is populated +with credentials and submitted, showing the console. For iLO 5 the main login +page is loaded, and when the login is submitted ``irc.html`` is loaded again. + +Supermicro (Experimental) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +A simulated user logs in, waits for the console preview image to load, then +clicks on it. + diff --git a/tools/vnc-container/bin/start-browser-x11vnc.sh b/tools/vnc-container/bin/start-browser-x11vnc.sh new file mode 100755 index 0000000000..822695a5b7 --- /dev/null +++ b/tools/vnc-container/bin/start-browser-x11vnc.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -eux + +x11vnc -nevershared -forever -afteraccept 'start-selenium-browser.py &' -gone 'killall -s SIGTERM python3' \ No newline at end of file diff --git a/tools/vnc-container/bin/start-selenium-browser.py b/tools/vnc-container/bin/start-selenium-browser.py new file mode 100755 index 0000000000..bf60c0f1e8 --- /dev/null +++ b/tools/vnc-container/bin/start-selenium-browser.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 + +import json +import os +import requests +from requests import auth +import signal +import sys +import time +from urllib import parse as urlparse + +from selenium import webdriver +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.common.by import By +from selenium.common import exceptions + + +class BaseApp: + + def __init__(self, app_info): + self.app_info = app_info + + @property + def url(self): + pass + + def handle_exit(self, signum, frame): + print("got SIGTERM, quitting") + self.driver.quit() + sys.exit(0) + + def start(self, driver): + self.driver = driver + signal.signal(signal.SIGTERM, self.handle_exit) + + +class FakeApp(BaseApp): + + @property + def url(self): + return "file:///drivers/fake/index.html" + + +class RedfishApp(BaseApp): + + @property + def base_url(self): + return self.app_info["address"] + + @property + def redfish_url(self): + return self.base_url + self.app_info.get("root_prefix", "/redfish/v1") + + def disable_right_click(self, driver): + # disable right-click menu + driver.execute_script( + 'window.addEventListener("contextmenu", function(e) ' + "{ e.preventDefault(); })" + ) + + +class IdracApp(RedfishApp): + + @property + def url(self): + username = self.app_info["username"] + password = self.app_info["password"] + verify = self.app_info.get("verify_ca", True) + kvm_session_url = (f"{self.redfish_url}/Managers/iDRAC.Embedded.1/Oem/" + "Dell/DelliDRACCardService/Actions/DelliDRACCardService.GetKVMSession") + netloc = urlparse.urlparse(self.base_url).netloc + + r = requests.post( + kvm_session_url, + verify=verify, + timeout=60, + auth=auth.HTTPBasicAuth(username, password), + json={"SessionTypeName": "idrac-graphical"}, + ).json() + temp_username = r["TempUsername"] + temp_password = r["TempPassword"] + url = (f"{self.base_url}/restgui/vconsole/index.html?ip={netloc}&" + f"kvmport=443&title=idrac-graphical&VCSID={temp_username}&VCSID2={temp_password}") + return url + + def start(self, driver): + super(IdracApp, self).start(driver) + # wait for the full screen button + wait = WebDriverWait( + driver, + timeout=10, + poll_frequency=0.2, + ignored_exceptions=[exceptions.NoSuchElementException], + ) + wait.until( + lambda d: driver.find_element(By.TAG_NAME, value="full-screen") + or True + ) + fs_tag = driver.find_element(By.TAG_NAME, value="full-screen") + fs_tag.find_element(By.TAG_NAME, "button").click() + + +class IloApp(RedfishApp): + + @property + def url(self): + return self.base_url + "/irc.html" + + def login(self, driver): + + username = self.app_info["username"] + password = self.app_info["password"] + # wait for the username field to be enabled then perform login + wait = WebDriverWait( + driver, + timeout=10, + poll_frequency=0.2, + ignored_exceptions=[exceptions.NoSuchElementException], + ) + wait.until( + lambda d: driver.find_element(By.ID, value="username") or True + ) + + username_field = driver.find_element(By.ID, value="username") + wait = WebDriverWait( + driver, + timeout=5, + poll_frequency=0.2, + ignored_exceptions=[exceptions.ElementNotInteractableException], + ) + wait.until(lambda d: username_field.send_keys(username) or True) + + driver.find_element(By.ID, value="password").send_keys(password) + driver.find_element(By.ID, value="login-form__submit").click() + + def start(self, driver): + super(IloApp, self).start(driver) + + # Detect iLO 6 vs 5 based on whether a message box or a login form + # is presented + try: + driver.find_element(By.CLASS_NAME, value="loginBoxRestrictWidth") + is_ilo6 = True + except exceptions.NoSuchElementException: + is_ilo6 = False + + if is_ilo6: + # iLO 6 has an inline login which matches the main login + self.login(driver) + self.disable_right_click(driver) + self.full_screen(driver) + return + + # load the main login page + driver.get(self.base_url) + + # full screen content is shown in an embedded iframe + iframe = driver.find_element(By.ID, "appFrame") + driver.switch_to.frame(iframe) + + self.login(driver) + + # wait for <body id="app-container"> to exist, which indicates + # the login form has submitted and session cookies are now set + wait = WebDriverWait( + driver, + timeout=10, + poll_frequency=0.2, + ignored_exceptions=[exceptions.NoSuchElementException], + ) + wait.until( + lambda d: driver.find_element(By.ID, value="app-container") + or True + ) + + # load the actual console + driver.get(self.url) + self.disable_right_click(driver) + self.full_screen(driver) + + def full_screen(self, driver): + # make console full screen to hide menu + fs_button = driver.find_element( + By.CLASS_NAME, value="btnVideoFullScreen" + ) + wait = WebDriverWait( + driver, + timeout=20, + poll_frequency=0.2, + ignored_exceptions=[ + exceptions.ElementNotInteractableException, + exceptions.ElementClickInterceptedException, + ], + ) + wait.until(lambda d: fs_button.click() or True) + + +class SupermicroApp(RedfishApp): + + @property + def url(self): + return self.base_url + + def start(self, driver): + super(SupermicroApp, self).start(driver) + username = self.app_info["username"] + password = self.app_info["password"] + + # populate login and submit + driver.find_element(By.NAME, value="name").send_keys(username) + driver.find_element(By.ID, value="pwd").send_keys(password) + driver.find_element(By.ID, value="login_word").click() + + # navigate down some iframes + iframe = driver.find_element(By.ID, "TOPMENU") + driver.switch_to.frame(iframe) + + iframe = driver.find_element(By.ID, "frame_main") + driver.switch_to.frame(iframe) + + wait = WebDriverWait( + driver, + timeout=30, + poll_frequency=0.2, + ignored_exceptions=[ + exceptions.NoSuchElementException, + exceptions.ElementNotInteractableException, + ], + ) + wait.until(lambda d: driver.find_element(By.ID, value="img1") or True) + + # launch the console by waiting for the console preview image to be + # loaded and clickable + def snapshot_wait(d): + try: + img1 = driver.find_element(By.ID, value="img1") + except exceptions.NoSuchElementException: + print("img1 doesn't exist yet") + return False + + if "Snapshot" not in img1.get_attribute("src"): + print("img1 src not a console snapshot yet") + return False + if not img1.get_attribute("complete") == "true": + print("img1 console snapshot not loaded yet") + return False + try: + img1.click() + except exceptions.ElementNotInteractableException: + print("img1 not clickable yet") + return False + return True + + wait = WebDriverWait(driver, timeout=30, poll_frequency=1) + wait.until(snapshot_wait) + + # self.disable_right_click(driver) + + +def start_driver(url, app_info): + print(f"starting app with url {url}") + opts = webdriver.ChromeOptions() + opts.binary_location = "/usr/bin/chromium-browser" + # opts.enable_bidi = True + if url: + opts.add_argument(f"--app={url}") + + verify = app_info.get("verify_ca", True) + if not verify: + opts.add_argument("--ignore-certificate-errors") + opts.add_argument("--ignore-ssl-errors") + + opts.add_argument("--disable-extensions") + opts.add_argument("--disable-gpu") + opts.add_argument("--disable-plugins-discovery") + + opts.add_argument("--disable-context-menu") + opts.add_argument("--no-sandbox") + opts.add_argument("--disable-dev-shm-usage") + + opts.add_argument("--window-position=0,0") + opts.add_experimental_option("excludeSwitches", ["enable-automation"]) + if "DISPLAY_WIDTH" in os.environ and "DISPLAY_HEIGHT" in os.environ: + width = int(os.environ["DISPLAY_WIDTH"]) + height = int(os.environ["DISPLAY_HEIGHT"]) + opts.add_argument(f"--window-size={width},{height}") + if "CHROME_ARGS" in os.environ: + for arg in os.environ["CHROME_ARGS"].split(" "): + opts.add_argument(arg) + + driver = webdriver.Chrome(options=opts) + driver.delete_all_cookies() + driver.set_window_position(0, 0) + + return driver + + +def discover_app(app_name, app_info): + if app_name == "fake": + return FakeApp + if app_name == "redfish-graphical": + # Make an unauthenticated redfish request + # to discover which console class to use + url = app_info["address"] + app_info.get("root_prefix", "/redfish/v1") + verify = app_info.get("verify_ca", True) + r = requests.get(url, verify=verify, timeout=60).json() + oem = ",".join(r["Oem"].keys()) + if "Hpe" in oem: + return IloApp + if "Dell" in oem: + return IdracApp + if "Supermicro" in oem: + return SupermicroApp + raise Exception(f"Unsupported {app_name} vendor {oem}") + + raise Exception(f"Unknown app name {app_name}") + + +def main(): + app_name = os.environ.get("APP") + print("got app info " + os.environ.get("APP_INFO")) + app_info = json.loads(os.environ.get("APP_INFO")) + app_class = discover_app(app_name, app_info) + + app = app_class(app_info) + + driver = start_driver(url=app.url, app_info=app_info) + print(f"got driver {driver}") + + print(f"Running app {app_name}") + app.start(driver) + while True: + time.sleep(10) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/vnc-container/bin/start-xvfb.sh b/tools/vnc-container/bin/start-xvfb.sh new file mode 100755 index 0000000000..2584ffe074 --- /dev/null +++ b/tools/vnc-container/bin/start-xvfb.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -eux + +xvfb-run -s "-screen 0 ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}x24" start-browser-x11vnc.sh \ No newline at end of file diff --git a/tools/vnc-container/drivers/fake/index.html b/tools/vnc-container/drivers/fake/index.html new file mode 100644 index 0000000000..19d94c57b2 --- /dev/null +++ b/tools/vnc-container/drivers/fake/index.html @@ -0,0 +1,80 @@ +<html> + <head> + <title>Bouncing Pixie</title> + <style> + * {margin:0; padding: 0; color:red;} + </style> + </head> + + <body> + <canvas id="tv-screen"></canvas> + <script> +let speed = 50; +let scale = 0.4; // Image scale (I work on 1080p monitor) +let canvas; +let ctx; +let logoColor; + +let dvd = { + x: 200, + y: 300, + xspeed: 10, + yspeed: 10, + img: new Image() +}; + +(function main(){ + canvas = document.getElementById("tv-screen"); + ctx = canvas.getContext("2d"); + dvd.img.src = 'ironic_mascot_color.png'; + + //Draw the "tv screen" + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + + pickColor(); + update(); +})(); + +function update() { + setTimeout(() => { + //Draw the canvas background + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + //Draw DVD Logo and his background + ctx.fillStyle = logoColor; + ctx.fillRect(dvd.x, dvd.y, dvd.img.width*scale, dvd.img.height*scale); + ctx.drawImage(dvd.img, dvd.x, dvd.y, dvd.img.width*scale, dvd.img.height*scale); + //Move the logo + dvd.x+=dvd.xspeed; + dvd.y+=dvd.yspeed; + //Check for collision + checkHitBox(); + update(); + }, speed) +} + +//Check for border collision +function checkHitBox(){ + if(dvd.x+dvd.img.width*scale >= canvas.width || dvd.x <= 0){ + dvd.xspeed *= -1; + pickColor(); + } + + if(dvd.y+dvd.img.height*scale >= canvas.height || dvd.y <= 0){ + dvd.yspeed *= -1; + pickColor(); + } +} + +//Pick a random color in RGB format +function pickColor(){ + r = Math.random() * (254 - 0) + 0; + g = Math.random() * (254 - 0) + 0; + b = Math.random() * (254 - 0) + 0; + + logoColor = 'rgb('+r+','+g+', '+b+')'; +} + </script> + </body> +</html> diff --git a/tools/vnc-container/drivers/fake/ironic_mascot_color.png b/tools/vnc-container/drivers/fake/ironic_mascot_color.png new file mode 100644 index 0000000000..7bf1424770 Binary files /dev/null and b/tools/vnc-container/drivers/fake/ironic_mascot_color.png differ