mirror of
https://github.com/Cian-H/I-Form_Server_Node_Deployer.git
synced 2025-12-24 07:02:01 +00:00
Better restructure, and improved debug
This commit is contained in:
0
src/node_deployer/__init__.py
Normal file
0
src/node_deployer/__init__.py
Normal file
31
src/node_deployer/__main__.py
Normal file
31
src/node_deployer/__main__.py
Normal 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()
|
||||
205
src/node_deployer/autoignition.py
Normal file
205
src/node_deployer/autoignition.py
Normal 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
36
src/node_deployer/cli.py
Normal 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
|
||||
73
src/node_deployer/config.py
Normal file
73
src/node_deployer/config.py
Normal 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")
|
||||
165
src/node_deployer/create_disk.py
Normal file
165
src/node_deployer/create_disk.py
Normal 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)
|
||||
180
src/node_deployer/create_img.py
Normal file
180
src/node_deployer/create_img.py
Normal 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)
|
||||
39
src/node_deployer/debug.py
Normal file
39
src/node_deployer/debug.py
Normal 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
|
||||
24
src/node_deployer/node_deployer.py
Executable file
24
src/node_deployer/node_deployer.py
Executable 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()
|
||||
22
src/node_deployer/utils.py
Normal file
22
src/node_deployer/utils.py
Normal 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
|
||||
Reference in New Issue
Block a user