Better restructure, and improved debug

This commit is contained in:
Cian Hughes
2023-11-01 13:45:08 +00:00
parent f5816ee17e
commit cba4743035
16 changed files with 454 additions and 219 deletions

View File

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python
def main() -> None:
from . import config
config.update_config("cli")
from .node_deployer import app
app()
def debug():
from . import config
config.update_config("debug")
from .node_deployer import app
# Below, we set the default value of the debug flag
# for the base function of each command to True
def unwrap(f):
if hasattr(f, "__wrapped__"):
return unwrap(f.__wrapped__)
else:
return f
for c in app.registered_commands:
f = unwrap(c.callback)
defaults = list(f.__defaults__)
defaults[-1] = True
f.__defaults__ = tuple(defaults)
app()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,205 @@
from fnmatch import fnmatch
import io
from pathlib import Path
import tarfile
import time
from typing import Annotated
import git
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
import typer
from . import config
from .cli import cli_spinner
from .debug import debug_guard
from .utils import ensure_build_dir
def create_driver():
driver = webdriver.Remote(
"http://127.0.0.1:4444",
options=webdriver.FirefoxOptions(),
)
driver.implicitly_wait(10)
return driver
def convert_json_via_fuelignition(container, driver, fuelignition_json, img_path):
driver.get(config.FUELIGNITION_URL)
# Navigate to "Load Settings from" and upload the json
load_from = driver.find_element(By.NAME, "load_from")
load_from.send_keys(str(config.CWD_MOUNTDIR / fuelignition_json))
# Walk through page structure to find, scroll to and click "Convert and Download"
export = driver.find_element(By.ID, "export")
export_divs = export.find_elements(By.TAG_NAME, "div")
convert_div = export_divs[9]
convert_button = convert_div.find_element(By.TAG_NAME, "button")
# Ensure "Downloads" is empty if it exists
container.exec_run("[ -d /home/seluser/Downloads/* ] && rm /home/seluser/Downloads/*")
# A hacky way of scrolling to the element, but is only way i can find right now
convert_button.location_once_scrolled_into_view
time.sleep(1)
w = WebDriverWait(driver, 10)
w.until_not(EC.invisibility_of_element(convert_button))
w.until(EC.element_to_be_clickable(convert_button))
convert_button.click()
# Now, wait for the file to be downloaded
while container.exec_run("ls /home/seluser/Downloads/").exit_code != 0:
time.sleep(0.1)
while ".img.part" in container.exec_run("ls /home/seluser/Downloads/").output.decode():
time.sleep(0.1)
image_file = container.exec_run("ls /home/seluser/Downloads/").output.decode().split()[0]
# Finally, fetch the image file from the container
client_image_path = f"/home/seluser/Downloads/{image_file}"
host_image_path = config.SRC_DIR / img_path
if host_image_path.exists():
host_image_path.unlink()
filestream = container.get_archive(client_image_path)[0]
# unpack the tarfile in memory
bytestream = io.BytesIO(b"".join(chunk for chunk in filestream))
bytestream.seek(0)
tar = tarfile.open(fileobj=bytestream)
with open(host_image_path, "wb+") as f:
f.write(tar.extractfile(tar.getmembers()[0].name).read())
def build_fuelignition():
# Make sure the local fuel-ignition repo is up to date
if (not config.FUELIGNITION_BUILD_DIR.exists()) or (
len(tuple(config.FUELIGNITION_BUILD_DIR.iterdir())) == 0
):
repo = git.Repo.clone_from(
"https://github.com/openSUSE/fuel-ignition.git",
config.FUELIGNITION_BUILD_DIR,
branch="main",
)
else:
repo = git.Repo(config.FUELIGNITION_BUILD_DIR)
repo.remotes.origin.update()
repo.remotes.origin.pull()
# Then, build the docker image using the Dockerfile in the repo
# * For the container to build, we need to use a patched Dockerfile
# * The patch manually creates a "fuelignition" usergroup
# * From reading up, this change to how docker creates usergroups
# * appears to have been introduced in engine version 9.3.0 and above?
# * For now, we're applying the patch @>=9.3.0 and can change later if needed
engine_version = tuple(
map(
int,
next(
filter(lambda x: x.get("Name") == "Engine", config.CLIENT.version()["Components"])
)["Version"].split("."),
)
)
root_container = (engine_version[0] > 9) or (engine_version[0] == 9 and engine_version[1] >= 3)
dockerfile = "Dockerfile"
if root_container:
dockerfile = config.DOCKERFILE_DIR / "fuel-ignition.dockerfile"
image, _ = config.CLIENT.images.build(
path=str(config.FUELIGNITION_BUILD_DIR),
dockerfile=str(dockerfile),
tag="fuel-ignition",
network_mode="host",
buildargs={"CONTAINER_USERID": "1000"},
pull=True,
quiet=True,
rm=config.CLEANUP_IMAGES,
)
return image
@debug_guard
@cli_spinner(description="Converting json to img", total=None)
@ensure_build_dir
def json_to_img(
json_path: Annotated[
Path,
typer.Option(
"--json-path", "-i",
help="The fuel-ignition json for configuring the disk image",
prompt=True,
exists=True,
dir_okay=False,
),
] = Path("fuelignition.json"),
img_path: Annotated[
Path,
typer.Option(
"--img-path", "-o",
help="The file to output the disk image to",
prompt=True,
dir_okay=False,
writable=True,
readable=False,
),
] = Path("ignition.img"),
debug: Annotated[
bool,
typer.Option(
"--debug",
help="Enable debug mode",
is_eager=True,
is_flag=True,
flag_value=True,
expose_value=config.DEBUG,
hidden=not config.DEBUG,
)
] = False,
) -> None:
"""Takes a fuel-ignition json file and produces an ignition disk image file"""
selenium_container = None
fuelignition_container = None
fuelignition_image = None
try:
# Initialise containers
selenium_container = config.CLIENT.containers.run(
"selenium/standalone-firefox:latest",
detach=True,
remove=True,
ports={4444: 4444, 7900: 7900},
mounts=[
config.CWD_MOUNT,
],
)
fuelignition_image = build_fuelignition()
fuelignition_container = config.CLIENT.containers.run(
fuelignition_image,
detach=True,
remove=True,
network_mode=f"container:{selenium_container.id}",
)
# Wait for the containers to finish starting up
while config.SELENIUM_INIT_MESSAGE not in selenium_container.logs().decode():
time.sleep(0.1)
for event in config.CLIENT.events(decode=True):
print(event)
while not fnmatch(
fuelignition_container.logs().decode().strip().split("\n")[-1].strip(),
config.FUELIGNITION_INIT_MESSAGE,
):
time.sleep(0.1)
# Now, create the webdriver and convert the json to an img
driver = create_driver()
convert_json_via_fuelignition(selenium_container, driver, json_path, img_path)
driver.quit()
except Exception as e:
raise e
finally:
if selenium_container is not None:
selenium_image = selenium_container.image
selenium_container.kill()
if config.CLEANUP_IMAGES:
selenium_image.remove(force=True)
if fuelignition_container is not None:
fuelignition_container.kill()
if fuelignition_image is not None:
if config.CLEANUP_IMAGES:
fuelignition_image.remove(force=True)
if __name__ == "__main__":
config.update_config("cli")
typer.run(json_to_img)

36
src/node_deployer/cli.py Normal file
View File

@@ -0,0 +1,36 @@
from functools import wraps
import inspect
from typing import Callable
from rich.progress import Progress, SpinnerColumn, TextColumn
from . import config
from .utils import Singleton
class SingletonProgress(Progress, metaclass=Singleton):
pass
def cli_spinner(*spinner_args, **spinner_kwargs) -> Callable:
def decorator(f: Callable) -> Callable:
# Indent the spinner to match its nesting level
indent = len(inspect.stack()) - 1
spinner_kwargs["indent"] = f"{""*indent}"
@wraps(f)
def wrapped(*func_args, **func_kwargs):
if not config.CLI:
return f(*func_args, **func_kwargs)
with SingletonProgress(
SpinnerColumn(),
TextColumn("{task.fields[indent]}[progress.description]{task.description}"),
transient=True,
expand=True,
) as progress:
task_id = progress.add_task(*spinner_args, **spinner_kwargs)
out = f(*func_args, **func_kwargs)
progress.stop_task(task_id)
return out
return wrapped
return decorator

View File

@@ -0,0 +1,73 @@
# flake8: noqa: F821
# type: ignore
#* This file sets a number of config constants by modifying its own globals
#* As a result, F821 and typing is disabled as the interpreter cannot be
#* trusted to know when F821 or UndefinedVeriable errors should be raised.
from pathlib import Path
import docker
import tomllib
CLIENT = docker.from_env(version="auto")
MAX_PORT: int = 65535
PROJECT_ROOT: Path = Path(__file__).parent.parent.parent.absolute()
type ConfigLabel = str | list[str]
def get_config(config_label: ConfigLabel = ["default"]) -> dict:
if isinstance(config_label, str):
config_label = [config_label]
with open(PROJECT_ROOT / "config.toml", "rb") as f:
configs: dict = tomllib.load(f)
out_config: dict = {}
for c in config_label:
out_config.update(configs[c])
return out_config
def finalise_config(config: dict) -> None:
# First, convert base paths to Path objects
for k, v in config.items():
match k:
case "SRC_DIR" | "BUILD_DIR":
config[k] = Path(v).absolute()
case "CWD_MOUNTDIR":
config[k] = Path(v)
# Then, get required paths from config or globals if not present
build_dir = config.get("BUILD_DIR", BUILD_DIR)
cwd_mountdir = config.get("CWD_MOUNTDIR", CWD_MOUNTDIR)
src_dir = config.get("SRC_DIR", SRC_DIR)
# Finally, construct the secondary parameters
config["FUELIGNITION_BUILD_DIR"] = build_dir / config.get(
"FUELIGNITION_BUILD_DIR",
FUELIGNITION_BUILD_DIR
)
config["DOCKERFILE_DIR"] = src_dir / config.get(
"DOCKERFILE_DIR",
DOCKERFILE_DIR
)
config["CWD_MOUNT"] = docker.types.Mount(
target=str(cwd_mountdir),
source=str(PROJECT_ROOT),
type="bind",
)
def apply_config(config: dict) -> None:
finalise_config(config)
globals().update(config)
def update_config(config_label: ConfigLabel = "default") -> None:
apply_config(get_config(config_label))
def init(config) -> None:
globals().update(get_config(config))
update_config()
init(config="default")

View File

@@ -0,0 +1,165 @@
from fnmatch import fnmatch
import ipaddress
from typing import Annotated
from docker.types import Mount
import typer
from . import config
from .cli import cli_spinner
from .create_img import create_img
from .debug import debug_guard
from .utils import ensure_build_dir
type IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
def filter_validation_response(response: str) -> str:
return "\n".join(
filter(
# Filter out the warning about unused key human_readable, this always exists in
# configurations produced by fuel-ignition
lambda x: not fnmatch(x.strip(), "warning at*Unused key human_read"),
response.split("\n"),
)
).strip()
def validation_result() -> str:
dockerfile = config.DOCKERFILE_DIR / "validate.dockerfile"
image, _ = config.CLIENT.images.build(
path=".",
dockerfile=str(dockerfile),
tag="validate",
buildargs={"CWD_MOUNTDIR": str(config.CWD_MOUNTDIR)},
rm=config.CLEANUP_IMAGES,
pull=True,
quiet=True,
)
response = config.CLIENT.containers.run(
image,
mounts=[
config.CWD_MOUNT,
],
remove=True,
)
if config.CLEANUP_IMAGES:
image.remove(force=True)
return response
@cli_spinner(description="Validating ignition image", total=None)
def validate() -> (bool, str):
response = validation_result().decode()
response = filter_validation_response(response)
return (not bool(response), response)
@cli_spinner(description="Writing ignition image to disk", total=None)
def write_disk(disk: str) -> None:
config.CLIENT.containers.run(
"alpine",
mounts=[config.CWD_MOUNT, Mount("/ignition_disk", disk, type="bind")],
privileged=True,
command=f"dd if={config.CWD_MOUNTDIR}/build/ignition.img of=/ignition_disk",
)
@debug_guard
@cli_spinner(description="Creating ignition initialisation disk", total=None)
@ensure_build_dir
def create_ignition_disk(
disk: Annotated[
str,
typer.Option(
"--disk",
"-d",
help="Path to the disk to write to",
prompt=True,
),
] = None,
hostname: Annotated[
str,
typer.Option(
"--hostname",
"-h",
help="Hostname for the new node",
prompt=True,
),
] = "node",
password: Annotated[
str,
typer.Option(
"--password",
"-p",
help="Password for the root user on the new node",
prompt=True,
confirmation_prompt=True,
hide_input=True,
),
] = None,
switch_ip: Annotated[
IPAddress,
typer.Option(
"--switch-ip",
"-ip",
help="IP address of the switch to connect to",
prompt=True,
parser=ipaddress.ip_address,
),
] = None,
switch_port: Annotated[
int,
typer.Option(
"--switch-port",
"-sp",
help="Port on the switch to connect to",
prompt=True,
min=1,
max=config.MAX_PORT,
),
] = 4789,
swarm_token: Annotated[
str,
typer.Option(
"--swarm-token",
"-t",
help="Swarm token for connecting to the swarm",
prompt=True,
),
] = None,
debug: Annotated[
bool,
typer.Option(
"--debug",
help="Enable debug mode",
is_eager=True,
is_flag=True,
flag_value=True,
hidden=not config.DEBUG,
)
] = False,
) -> None:
"""Writes an ignition image to the specified disk for easy deployment of new nodes to the swarm""" # noqa
create_img(
hostname = hostname,
password = password,
switch_ip = switch_ip,
switch_port = switch_port,
swarm_token = swarm_token,
img_path = config.BUILD_DIR / "ignition.img",
debug = debug,
)
valid, response = validate()
if not valid:
print(response)
raise typer.Exit(1)
else:
print("Valid ignition image created!")
write_disk(disk)
if __name__ == "__main__":
config.update_config("cli")
typer.run(create_ignition_disk)

View File

@@ -0,0 +1,180 @@
import ipaddress
import json
from pathlib import Path
from typing import Annotated
import typer
from . import config
from .autoignition import json_to_img
from .cli import cli_spinner
from .debug import debug_guard
from .utils import ensure_build_dir
type IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
def load_template() -> dict:
with open(config.SRC_DIR / "templates/fuelignition.json", "r") as f:
out = json.load(f)
return out
def apply_ignition_settings(
template: dict,
hostname: str,
password: str,
swarm_config: str,
) -> dict:
ignition_config = template.copy()
ignition_config["hostname"] = hostname
ignition_config["login"]["users"][0]["passwd"] = password
# Add files that will define a service to ensure that the node joins the swarm
with open(config.SRC_DIR / "templates/join_swarm.sh", "r") as f1, open(
config.SRC_DIR / "templates/join_swarm.service", "r"
) as f2:
swarm_script, swarm_service = f1.read(), f2.read()
ignition_config["storage"] = ignition_config.get("storage", {})
ignition_config["storage"]["files"] = ignition_config["storage"].get("files", [])
ignition_config["storage"]["files"] += [
{
"path": "/root/join_swarm.json",
"source_type": "data",
"mode": 420,
"overwrite": True,
"data_content": swarm_config,
},
{
"path": "/root/join_swarm.sh",
"source_type": "data",
"mode": 420,
"overwrite": True,
"data_content": swarm_script,
},
]
ignition_config["systemd"] = ignition_config.get("systemd", {})
ignition_config["systemd"]["units"] = ignition_config["systemd"].get("units", [])
ignition_config["systemd"]["units"] += [
{
"name": "join_swarm.service",
"enabled": True,
"contents": swarm_service,
},
]
return ignition_config
@debug_guard
@cli_spinner(description="Creating ignition image", total=None)
@ensure_build_dir
def create_img(
hostname: Annotated[
str,
typer.Option(
"--hostname",
"-h",
help="Hostname for the new node",
prompt=True,
),
] = "node",
password: Annotated[
str,
typer.Option(
"--password",
"-p",
help="Password for the root user on the new node",
prompt=True,
confirmation_prompt=True,
hide_input=True,
),
] = None,
switch_ip: Annotated[
IPAddress,
typer.Option(
"--switch-ip",
"-ip",
help="IP address of the switch to connect to",
prompt=True,
parser=ipaddress.ip_address,
),
] = None,
switch_port: Annotated[
int,
typer.Option(
"--switch-port",
"-sp",
help="Port on the switch to connect to",
prompt=True,
min=1,
max=config.MAX_PORT,
),
] = 4789,
swarm_token: Annotated[
str,
typer.Option(
"--swarm-token",
"-t",
help="Swarm token for connecting to the swarm",
prompt=True,
),
] = None,
img_path: Annotated[
Path,
typer.Option(
"--img-path",
"-o",
help="Path to which the ignition image should be written",
dir_okay=False,
),
] = Path("ignition.img"),
debug: Annotated[
bool,
typer.Option(
"--debug",
help="Enable debug mode",
is_eager=True,
is_flag=True,
flag_value=True,
hidden=not config.DEBUG,
),
] = False,
) -> None:
"""Creates an ignition image for deploying a new node to the swarm"""
# get swarm configuration as JSON
swarm_config = json.dumps(
{
"SWITCH_IP_ADDRESS": str(switch_ip),
"SWITCH_PORT": switch_port,
"SWARM_TOKEN": swarm_token,
}
)
# Create ignition configuration
ignition_config = load_template()
ignition_config = apply_ignition_settings(
ignition_config,
hostname,
password,
swarm_config,
)
# export ignition configuration
with open(config.BUILD_DIR / "fuelignition.json", "w") as f:
json.dump(ignition_config, f, indent=4)
# convert ignition configuration to image
json_to_img(
json_path=config.BUILD_DIR / "fuelignition.json",
img_path=img_path,
debug=debug,
)
if __name__ == "__main__":
config.update_config("cli")
typer.run(create_img)

View File

@@ -0,0 +1,39 @@
from functools import wraps
import inspect
from typing import Callable
import typer
from . import config
# def merge(f1: Callable) -> Callable:
# https://docs.python.org/3/library/functools.html#functools.update_wrapper
# wraps, but it combines the signatures of the two functions
# This will allow us to add/remove the `debug` arg depending on config context
def debug_guard(f: Callable) -> Callable:
if not config.DEBUG:
return f
try:
import snoop # type: ignore
except ImportError:
typer.echo("Debug mode requires the dev group to be installed")
raise typer.Exit(1)
else:
snoop.install(**config.snoop["install"])
@wraps(f)
def debug_mode(
*args,
**kwargs,
) -> Callable:
typer.echo(f"Debug mode enabled: {inspect.stack()[1].filename}")
if kwargs.get("debug", False):
# Snoop depth is set to compensate for wrapper stack frames
return snoop.snoop(**config.snoop["snoop"])(f)(*args, **kwargs) # noqa: F821 #* ss is installed in debug_mode
else:
return f(*args, **kwargs)
return debug_mode

View File

@@ -0,0 +1,24 @@
import typer
from . import config
from .autoignition import json_to_img
from .create_disk import create_ignition_disk
from .create_img import create_img
cmd_params = {
"no_args_is_help": True,
}
app = typer.Typer(
help="A tool for creating ignition images for automated deployment to a swarm",
**cmd_params,
)
app.command(**cmd_params)(create_img)
app.command(**cmd_params)(create_ignition_disk)
app.command(**cmd_params)(json_to_img)
if __name__ == "__main__":
config.update_config("cli")
app()

View File

@@ -0,0 +1,22 @@
from functools import wraps
from pathlib import Path
from typing import Callable
from . import config
def ensure_build_dir(f: Callable) -> Callable:
@wraps(f)
def wrapper(*args, **kwargs):
Path(config.BUILD_DIR).mkdir(exist_ok=True, parents=True)
return f(*args, **kwargs)
return wrapper
class Singleton(type):
_instance = None
def __call__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__call__(*args, **kwargs)
return cls._instance