restructured project to have /src folder

This commit is contained in:
Cian Hughes
2023-10-31 09:44:34 +00:00
parent 1b22fd7861
commit 289922bea7
16 changed files with 2 additions and 1 deletions

177
src/autoignition.py Normal file
View File

@@ -0,0 +1,177 @@
from fnmatch import fnmatch
import io
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 cli import cli_spinner
import config
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.ROOT_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(
fuelignition_json: Annotated[
str, typer.Option(help="The fuel-ignition json for configuring the disk image", prompt=True)
],
img_path: Annotated[
str, typer.Option(help="The file to output the disk image to", prompt=True)
],
) -> 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, fuelignition_json, 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/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
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

67
src/config.py Normal file
View File

@@ -0,0 +1,67 @@
# flake8: noqa: F821
#* This file sets a number of config constants by modifying its own globals
#* As a result, F821 is disabled as the intereter cannot be trusted to know
#* when F821 should be raised.
from pathlib import Path
import tomllib
import docker
CLIENT = docker.from_env(version="auto")
ROOT = Path(__file__).parent.parent
type ConfigLabel = str | list[str]
def get_config(config_label: ConfigLabel = ["default"]) -> dict:
if isinstance(config_label, str):
config_label = [config_label]
with open(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 "ROOT_DIR" | "BUILD_DIR" | "DOCKERFILE_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)
root_dir = config.get("ROOT_DIR", ROOT_DIR)
# Finally, construct the secondary parameters
config["FUELIGNITION_BUILD_DIR"] = build_dir / config.get(
"FUELIGNITION_BUILD_DIR",
FUELIGNITION_BUILD_DIR
)
config["CWD_MOUNT"] = docker.types.Mount(
target=str(cwd_mountdir),
source=str(root_dir),
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() -> None:
globals().update(get_config())
update_config()
init()

101
src/create_disk.py Normal file
View File

@@ -0,0 +1,101 @@
from fnmatch import fnmatch
from typing import Annotated
import typer
from cli import cli_spinner
import config
from create_img import create_img
from debug import debug_guard
from docker.types import Mount
from utils import ensure_build_dir
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(help="Path to the disk to write to", prompt=True)],
hostname: Annotated[str, typer.Option(help="Hostname for the new node", prompt=True)],
password: Annotated[
str,
typer.Option(
help="Password for the root user on the new node",
prompt=True,
confirmation_prompt=True,
hide_input=True,
),
],
switch_ip_address: Annotated[
str, typer.Option(help="IP address of the switch to connect to", prompt=True)
],
switch_port: Annotated[int, typer.Option(help="Port on the switch to connect to", prompt=True)],
swarm_token: Annotated[
str, typer.Option(help="Swarm token for connecting to the swarm", prompt=True)
],
) -> None:
"""Writes an ignition image to the specified disk for easy deployment of new nodes to the swarm""" # noqa
create_img(hostname, password, switch_ip_address, switch_port, swarm_token)
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)

126
src/create_img.py Normal file
View File

@@ -0,0 +1,126 @@
import ipaddress
import json
from typing import Annotated
import typer
from autoignition import json_to_img
from cli import cli_spinner
import config
from debug import debug_guard
from utils import ensure_build_dir
MAX_PORT: int = 65535
def load_template() -> dict:
with open("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("templates/join_swarm.sh", "r") as f1, open(
"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(help="Hostname for the new node", prompt=True)],
password: Annotated[
str,
typer.Option(
help="Password for the root user on the new node",
prompt=True,
confirmation_prompt=True,
hide_input=True,
),
],
switch_ip_address: Annotated[
str, typer.Option(help="IP address of the switch to connect to", prompt=True)
],
switch_port: Annotated[int, typer.Option(help="Port on the switch to connect to", prompt=True)],
swarm_token: Annotated[
str, typer.Option(help="Swarm token for connecting to the swarm", prompt=True)
],
) -> None:
"""Creates an ignition image for deploying a new node to the swarm"""
switch_ip_address = ipaddress.ip_address(switch_ip_address)
if switch_port > MAX_PORT:
raise ValueError(f"Port must be less than {MAX_PORT}")
# get swarm configuration as JSON
swarm_config = json.dumps(
{
"SWITCH_IP_ADDRESS": str(switch_ip_address),
"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("build/fuelignition.json", "w") as f:
json.dump(ignition_config, f, indent=4)
# convert ignition configuration to image
json_to_img("build/fuelignition.json", "build/ignition.img")
if __name__ == "__main__":
config.update_config("cli")
typer.run(create_img)

24
src/debug.py Normal file
View File

@@ -0,0 +1,24 @@
from functools import wraps
import inspect
from typing import Callable
import typer # type: ignore
import config
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 snoop package")
raise typer.Exit(1)
else:
snoop.install(
snoop="ss",
)
typer.echo(f"Debug mode enabled: {inspect.stack()[1].filename}")
wraps(f)(ss)(f) # noqa: F821 #* ss is installed in debug_mode

View File

@@ -0,0 +1,17 @@
FROM registry.suse.com/bci/nodejs
ARG CONTAINER_USERID
# Install requirements, create usergroup and add user
RUN zypper -n install --no-recommends mkisofs python3 make gcc gcc-c++
RUN groupadd fuelignition
RUN useradd -m -d /fuelignition fuelignition -u ${CONTAINER_USERID}
COPY --chown=fuelignition . /fuelignition/
USER fuelignition
WORKDIR /fuelignition
RUN npm install
# Run our command
CMD ["npm", "run", "dev"]

View File

@@ -0,0 +1,12 @@
FROM quay.io/coreos/ignition-validate:release AS ignition-validate
FROM alpine:latest as base
ARG CWD_MOUNTDIR
ENV CWD_MOUNTDIR=$CWD_MOUNTDIR
COPY --from=ignition-validate . .
COPY scripts/installs.sh /installs.sh
RUN /installs.sh
CMD $CWD_MOUNTDIR/scripts/validate.sh

18
src/node_deployer.py Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env poetry run python
import typer
from create_img import create_img
from create_disk import create_ignition_disk
from autoignition import json_to_img
app = typer.Typer(
help="A tool for creating ignition images for automated deployment to a swarm"
)
app.command()(create_img)
app.command()(create_ignition_disk)
app.command()(json_to_img)
if __name__ == "__main__":
app()

1
src/scripts/fetch_config.sh Executable file
View File

@@ -0,0 +1 @@
mcopy -n -i ${CWD_MOUNTDIR}/build/ignition.img ::ignition/config.ign ${CWD_MOUNTDIR}/build/config.ign

2
src/scripts/installs.sh Executable file
View File

@@ -0,0 +1,2 @@
apk update
apk add mtools

2
src/scripts/validate.sh Executable file
View File

@@ -0,0 +1,2 @@
${CWD_MOUNTDIR}/scripts/fetch_config.sh
/usr/local/bin/ignition-validate ${CWD_MOUNTDIR}/build/config.ign

View File

@@ -0,0 +1,42 @@
{
"login": {
"users": [
{
"name": "root",
"hash_type": "bcrypt"
}
]
},
"network": {
"interfaces": [
{
"name": "eth0",
"ipv4": {
"network_type": "DHCP",
"auto_dns_enabled": true
},
"ipv6": {
"network_type": "DHCP",
"auto_dns_enabled": true
}
}
]
},
"systemd": {
"units": [
{
"name": "cockpit.socket.service",
"enabled": "yes"
},
{
"name": "docker.service",
"enabled": "yes"
}
]
},
"package": {
"install": [
"patterns-microos-cockpit, docker, jq"
]
}
}

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Ensure that node joins a swarm on startup
[Service]
ExecStart=/root/join_swarm.sh
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,16 @@
#!/bin/bash
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root"
exit 1
fi
# Load the config file into variables
eval "$(jq -r 'to_entries[] | "export \(.key)=\(.value | @sh)"' /root/join_swarm.json)"
if [[ $(docker info | grep Swarm | awk '{print $2}') == "inactive" ]]; then
docker swarm join --token $SWARM_TOKEN [$SWITCH_IP_ADDRESS]:$SWITCH_PORT
else
echo "This node is already part of a swarm"
docker info -f json | jq .Swarm
fi

22
src/utils.py Normal file
View File

@@ -0,0 +1,22 @@
from functools import wraps
from pathlib import Path
from typing import Callable
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