Add vnc-container image build
The files in tools/vnc-container allow 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. If the devstack ir-novnc serivce is enabled then this container image will be built locally and ironic configured to used it for the systemd console container provider. This makes a devstack environment functional in accessing graphical consoles for Dell, HPE and Supermicro. Related-Bug: 2086715 Change-Id: I0842570cca22ac0e67d358c30225e8e08561f459
This commit is contained in:
parent
e41cb93eeb
commit
4ed44172b4
@ -99,4 +99,5 @@ zstd [devstack]
|
||||
|
||||
# For graphical console support
|
||||
podman [devstack]
|
||||
systemd-container [devstack]
|
||||
systemd-container [devstack]
|
||||
buildah [devstack]
|
||||
|
@ -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
|
||||
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
25
tools/vnc-container/Containerfile
Normal file
25
tools/vnc-container/Containerfile
Normal file
@ -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"]
|
74
tools/vnc-container/README.rst
Normal file
74
tools/vnc-container/README.rst
Normal file
@ -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.
|
||||
|
5
tools/vnc-container/bin/start-browser-x11vnc.sh
Executable file
5
tools/vnc-container/bin/start-browser-x11vnc.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eux
|
||||
|
||||
x11vnc -nevershared -forever -afteraccept 'start-selenium-browser.py &' -gone 'killall -s SIGTERM python3'
|
337
tools/vnc-container/bin/start-selenium-browser.py
Executable file
337
tools/vnc-container/bin/start-selenium-browser.py
Executable file
@ -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())
|
5
tools/vnc-container/bin/start-xvfb.sh
Executable file
5
tools/vnc-container/bin/start-xvfb.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eux
|
||||
|
||||
xvfb-run -s "-screen 0 ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}x24" start-browser-x11vnc.sh
|
80
tools/vnc-container/drivers/fake/index.html
Normal file
80
tools/vnc-container/drivers/fake/index.html
Normal file
@ -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>
|
BIN
tools/vnc-container/drivers/fake/ironic_mascot_color.png
Normal file
BIN
tools/vnc-container/drivers/fake/ironic_mascot_color.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 89 KiB |
Loading…
x
Reference in New Issue
Block a user