Initial commit of PVC Bootstrap system
Adds the PVC Bootstrap system, which allows the automated deployment of one or more PVC clusters.
This commit is contained in:
7
bootstrap-daemon/clusters.yaml.sample
Normal file
7
bootstrap-daemon/clusters.yaml.sample
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
# clusters.yml
|
||||
# This file defines a list of Clusters that pvcbootstrapd should be aware of.
|
||||
|
||||
clusters:
|
||||
- cluster1
|
||||
- cluster2
|
16
bootstrap-daemon/pvcbootstrapd-worker.service
Normal file
16
bootstrap-daemon/pvcbootstrapd-worker.service
Normal file
@ -0,0 +1,16 @@
|
||||
# Parallel Virtual Cluster Provisioner API provisioner worker unit file
|
||||
|
||||
[Unit]
|
||||
Description = Parallel Virtual Cluster Bootstrap API worker
|
||||
After = network-online.target
|
||||
|
||||
[Service]
|
||||
Type = simple
|
||||
WorkingDirectory = /usr/share/pvc
|
||||
Environment = PYTHONUNBUFFERED=true
|
||||
Environment = PVC_CONFIG_FILE=/etc/pvc/pvcbootstrapd.yaml
|
||||
ExecStart = /usr/share/pvc/pvcbootstrapd-worker.sh
|
||||
Restart = on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy = multi-user.target
|
40
bootstrap-daemon/pvcbootstrapd-worker.sh
Executable file
40
bootstrap-daemon/pvcbootstrapd-worker.sh
Executable file
@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# pvcbootstrapd-worker.py - API Celery worker daemon startup stub
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
CELERY_BIN="$( which celery )"
|
||||
|
||||
# This absolute hackery is needed because Celery got the bright idea to change how their
|
||||
# app arguments work in a non-backwards-compatible way with Celery 5.
|
||||
case "$( cat /etc/debian_version )" in
|
||||
10.*)
|
||||
CELERY_ARGS="worker --app pvcbootstrapd.flaskapi.celery --concurrency 99 --pool gevent --loglevel DEBUG"
|
||||
;;
|
||||
11.*)
|
||||
CELERY_ARGS="--app pvcbootstrapd.flaskapi.celery worker --concurrency 99 --pool gevent --loglevel DEBUG"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid Debian version found!"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
${CELERY_BIN} ${CELERY_ARGS}
|
||||
exit $?
|
24
bootstrap-daemon/pvcbootstrapd.py
Executable file
24
bootstrap-daemon/pvcbootstrapd.py
Executable file
@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# pvcbootstrapd.py - Bootstrap API daemon startup stub
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import pvcbootstrapd.Daemon # noqa: F401
|
||||
|
||||
pvcbootstrapd.Daemon.entrypoint()
|
16
bootstrap-daemon/pvcbootstrapd.service
Normal file
16
bootstrap-daemon/pvcbootstrapd.service
Normal file
@ -0,0 +1,16 @@
|
||||
# Parallel Virtual Cluster Bootstrap API daemon unit file
|
||||
|
||||
[Unit]
|
||||
Description = Parallel Virtual Cluster Bootstrap API daemon
|
||||
After = network-online.target
|
||||
|
||||
[Service]
|
||||
Type = simple
|
||||
WorkingDirectory = /usr/share/pvc
|
||||
Environment = PYTHONUNBUFFERED=true
|
||||
Environment = PVC_CONFIG_FILE=/etc/pvc/pvcbootstrapd.yaml
|
||||
ExecStart = /usr/share/pvc/pvcbootstrapd.py
|
||||
Restart = on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy = multi-user.target
|
91
bootstrap-daemon/pvcbootstrapd.yaml.sample
Normal file
91
bootstrap-daemon/pvcbootstrapd.yaml.sample
Normal file
@ -0,0 +1,91 @@
|
||||
---
|
||||
pvc:
|
||||
# Enable debug mode
|
||||
debug: true
|
||||
|
||||
# Deploy username
|
||||
deploy_username: deploy
|
||||
|
||||
# Database (SQLite) configuration
|
||||
database:
|
||||
# Path to the database file
|
||||
path: /srv/tftp/pvcbootstrapd.sql
|
||||
|
||||
# Flask API configuration
|
||||
api:
|
||||
# Listen address
|
||||
address: 10.199.199.254
|
||||
|
||||
# Listen port
|
||||
port: 9999
|
||||
|
||||
# Redis Celery queue configuration
|
||||
queue:
|
||||
# Connect address
|
||||
address: 127.0.0.1
|
||||
|
||||
# Connect port
|
||||
port: 6379
|
||||
|
||||
# Redis path (almost always 0)
|
||||
path: "/0"
|
||||
|
||||
# DNSMasq DHCP configuration
|
||||
dhcp:
|
||||
# Listen address
|
||||
address: 10.199.199.254
|
||||
|
||||
# Default gateway address
|
||||
gateway: 10.199.199.1
|
||||
|
||||
# Local domain
|
||||
domain: pvcbootstrap.local
|
||||
|
||||
# DHCP lease range start
|
||||
lease_start: 10.199.199.10
|
||||
|
||||
# DHCP lease range end
|
||||
lease_end: 10.199.199.99
|
||||
|
||||
# DHCP lease time
|
||||
lease_time: 1h
|
||||
|
||||
# DNSMasq TFTP configuration
|
||||
tftp:
|
||||
# Root TFTP path (contents of the "buildpxe.sh" output directory; generally read-only)
|
||||
root_path: "/srv/tftp/pvc-installer"
|
||||
|
||||
# Per-host TFTP path (almost always "/host" under "root_path"; must be writable)
|
||||
host_path: "/srv/tftp/pvc-installer/host"
|
||||
|
||||
# PVC Ansible repository configuration
|
||||
# Note: If "path" does not exist, "remote" will be cloned to it via Git using SSH private key "keyfile".
|
||||
# Note: The VCS will be refreshed regularly via the API in response to webhooks.
|
||||
ansible:
|
||||
# Path to the VCS repository
|
||||
path: "/var/home/joshua/pvc"
|
||||
|
||||
# Path to the deploy key (if applicable) used to clone and pull the repository
|
||||
keyfile: "/var/home/joshua/id_ed25519.joshua.key"
|
||||
|
||||
# Git remote URI for the repository
|
||||
remote: "ssh://git@git.bonifacelabs.ca:2222/bonifacelabs/pvc.git"
|
||||
|
||||
# Git branch to use
|
||||
branch: "master"
|
||||
|
||||
# Clusters configuration file
|
||||
clusters_file: "clusters.yml"
|
||||
|
||||
# Filenames of the various group_vars components of a cluster
|
||||
# Generally with pvc-ansible this will contain 2 files: "base.yml", and "pvc.yml"; refer to the
|
||||
# pvc-ansible documentation and examples for details on these files.
|
||||
# The third file, "bootstrap.yml", is used by pvcbootstrapd to map BMC MAC addresses to hosts and
|
||||
# to simplify hardware detection. It must be present or the cluster will not be bootstrapped.
|
||||
# Adjust these entries to match the actual filenames of your clusters; the pvc-ansible defaults
|
||||
# are provided here. All clusters using this pvcbootstrapd instance must share identical filenames
|
||||
# here.
|
||||
cspec_files:
|
||||
base: "base.yml"
|
||||
pvc: "pvc.yml"
|
||||
bootstrap: "bootstrap.yml"
|
33
bootstrap-daemon/pvcbootstrapd.yaml.template
Normal file
33
bootstrap-daemon/pvcbootstrapd.yaml.template
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
pvc:
|
||||
debug: true
|
||||
deploy_username: DEPLOY_USERNAME
|
||||
database:
|
||||
path: ROOT_DIRECTORY/pvcbootstrapd.sql
|
||||
api:
|
||||
address: BOOTSTRAP_ADDRESS
|
||||
port: 9999
|
||||
queue:
|
||||
address: 127.0.0.1
|
||||
port: 6379
|
||||
path: "/0"
|
||||
dhcp:
|
||||
address: BOOTSTRAP_ADDRESS
|
||||
gateway: BOOTSTRAP_ADDRESS
|
||||
domain: pvcbootstrap.local
|
||||
lease_start: BOOTSTRAP_DHCPSTART
|
||||
lease_end: BOOTSTRAP_DHCPEND
|
||||
lease_time: 1h
|
||||
tftp:
|
||||
root_path: "ROOT_DIRECTORY/tftp"
|
||||
host_path: "ROOT_DIRECTORY/tftp/host"
|
||||
ansible:
|
||||
path: "ROOT_DIRECTORY/repo"
|
||||
keyfile: "ROOT_DIRECTORY/id_ed25519"
|
||||
remote: "GIT_REMOTE"
|
||||
branch: "GIT_BRANCH"
|
||||
clusters_file: "clusters.yml"
|
||||
cspec_files:
|
||||
base: "base.yml"
|
||||
pvc: "pvc.yml"
|
||||
bootstrap: "bootstrap.yml"
|
276
bootstrap-daemon/pvcbootstrapd/Daemon.py
Executable file
276
bootstrap-daemon/pvcbootstrapd/Daemon.py
Executable file
@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Daemon.py - PVC HTTP API daemon
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import os
|
||||
import yaml
|
||||
import signal
|
||||
|
||||
from sys import argv
|
||||
|
||||
import pvcbootstrapd.lib.dnsmasq as dnsmasqd
|
||||
import pvcbootstrapd.lib.db as db
|
||||
import pvcbootstrapd.lib.git as git
|
||||
import pvcbootstrapd.lib.tftp as tftp
|
||||
|
||||
from distutils.util import strtobool as dustrtobool
|
||||
|
||||
# Daemon version
|
||||
version = "0.1"
|
||||
|
||||
# API version
|
||||
API_VERSION = 1.0
|
||||
|
||||
|
||||
##########################################################
|
||||
# Exceptions
|
||||
##########################################################
|
||||
|
||||
|
||||
class MalformedConfigurationError(Exception):
|
||||
"""
|
||||
An exception when parsing the PVC daemon configuration file
|
||||
"""
|
||||
|
||||
def __init__(self, error=None):
|
||||
self.msg = f"ERROR: Configuration file is malformed: {error}"
|
||||
|
||||
def __str__(self):
|
||||
return str(self.msg)
|
||||
|
||||
|
||||
##########################################################
|
||||
# Helper Functions
|
||||
##########################################################
|
||||
|
||||
|
||||
def strtobool(stringv):
|
||||
if stringv is None:
|
||||
return False
|
||||
if isinstance(stringv, bool):
|
||||
return bool(stringv)
|
||||
try:
|
||||
return bool(dustrtobool(stringv))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
##########################################################
|
||||
# Configuration Parsing
|
||||
##########################################################
|
||||
|
||||
|
||||
def get_config_path():
|
||||
try:
|
||||
return os.environ["PVCD_CONFIG_FILE"]
|
||||
except KeyError:
|
||||
print('ERROR: The "PVCD_CONFIG_FILE" environment variable must be set.')
|
||||
os._exit(1)
|
||||
|
||||
|
||||
def read_config():
|
||||
pvcbootstrapd_config_file = get_config_path()
|
||||
|
||||
print(f"Loading configuration from file '{pvcbootstrapd_config_file}'")
|
||||
|
||||
# Load the YAML config file
|
||||
with open(pvcbootstrapd_config_file, "r") as cfgfile:
|
||||
try:
|
||||
o_config = yaml.load(cfgfile, Loader=yaml.SafeLoader)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to parse configuration file: {e}")
|
||||
os._exit(1)
|
||||
|
||||
# Create the configuration dictionary
|
||||
config = dict()
|
||||
|
||||
# Get the base configuration
|
||||
try:
|
||||
o_base = o_config["pvc"]
|
||||
except KeyError as k:
|
||||
raise MalformedConfigurationError(f"Missing top-level category {k}")
|
||||
|
||||
for key in ["debug", "deploy_username"]:
|
||||
try:
|
||||
config[key] = o_base[key]
|
||||
except KeyError as k:
|
||||
raise MalformedConfigurationError(f"Missing first-level key {k}")
|
||||
|
||||
# Get the first-level categories
|
||||
try:
|
||||
o_database = o_base["database"]
|
||||
o_api = o_base["api"]
|
||||
o_queue = o_base["queue"]
|
||||
o_dhcp = o_base["dhcp"]
|
||||
o_tftp = o_base["tftp"]
|
||||
o_ansible = o_base["ansible"]
|
||||
except KeyError as k:
|
||||
raise MalformedConfigurationError(f"Missing first-level category {k}")
|
||||
|
||||
# Get the Datbase configuration
|
||||
for key in ["path"]:
|
||||
try:
|
||||
config[f"database_{key}"] = o_database[key]
|
||||
except Exception:
|
||||
raise MalformedConfigurationError(
|
||||
f"Missing second-level key '{key}' under 'database'"
|
||||
)
|
||||
|
||||
# Get the API configuration
|
||||
for key in ["address", "port"]:
|
||||
try:
|
||||
config[f"api_{key}"] = o_api[key]
|
||||
except Exception:
|
||||
raise MalformedConfigurationError(
|
||||
f"Missing second-level key '{key}' under 'api'"
|
||||
)
|
||||
|
||||
# Get the queue configuration
|
||||
for key in ["address", "port", "path"]:
|
||||
try:
|
||||
config[f"queue_{key}"] = o_queue[key]
|
||||
except Exception:
|
||||
raise MalformedConfigurationError(
|
||||
f"Missing second-level key '{key}' under 'queue'"
|
||||
)
|
||||
|
||||
# Get the DHCP configuration
|
||||
for key in [
|
||||
"address",
|
||||
"gateway",
|
||||
"domain",
|
||||
"lease_start",
|
||||
"lease_end",
|
||||
"lease_time",
|
||||
]:
|
||||
try:
|
||||
config[f"dhcp_{key}"] = o_dhcp[key]
|
||||
except Exception:
|
||||
raise MalformedConfigurationError(
|
||||
f"Missing second-level key '{key}' under 'dhcp'"
|
||||
)
|
||||
|
||||
# Get the TFTP configuration
|
||||
for key in ["root_path", "host_path"]:
|
||||
try:
|
||||
config[f"tftp_{key}"] = o_tftp[key]
|
||||
except Exception:
|
||||
raise MalformedConfigurationError(
|
||||
f"Missing second-level key '{key}' under 'tftp'"
|
||||
)
|
||||
|
||||
# Get the Ansible configuration
|
||||
for key in ["path", "keyfile", "remote", "branch", "clusters_file"]:
|
||||
try:
|
||||
config[f"ansible_{key}"] = o_ansible[key]
|
||||
except Exception:
|
||||
raise MalformedConfigurationError(
|
||||
f"Missing second-level key '{key}' under 'ansible'"
|
||||
)
|
||||
|
||||
# Get the second-level categories under Ansible
|
||||
try:
|
||||
o_ansible_cspec_files = o_ansible["cspec_files"]
|
||||
except KeyError as k:
|
||||
raise MalformedConfigurationError(
|
||||
f"Missing second-level category {k} under 'ansible'"
|
||||
)
|
||||
|
||||
# Get the Ansible CSpec Files configuration
|
||||
for key in ["base", "pvc", "bootstrap"]:
|
||||
try:
|
||||
config[f"ansible_cspec_files_{key}"] = o_ansible_cspec_files[key]
|
||||
except Exception:
|
||||
raise MalformedConfigurationError(
|
||||
f"Missing third-level key '{key}' under 'ansible/cspec_files'"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
config = read_config()
|
||||
|
||||
|
||||
##########################################################
|
||||
# Entrypoint
|
||||
##########################################################
|
||||
|
||||
|
||||
def entrypoint():
|
||||
import pvcbootstrapd.flaskapi as pvcbootstrapd # noqa: E402
|
||||
|
||||
# Print our startup messages
|
||||
print("")
|
||||
print("|----------------------------------------------------------|")
|
||||
print("| |")
|
||||
print("| ███████████ ▜█▙ ▟█▛ █████ █ █ █ |")
|
||||
print("| ██ ▜█▙ ▟█▛ ██ |")
|
||||
print("| ███████████ ▜█▙ ▟█▛ ██ |")
|
||||
print("| ██ ▜█▙▟█▛ ███████████ |")
|
||||
print("| |")
|
||||
print("|----------------------------------------------------------|")
|
||||
print("| Parallel Virtual Cluster Bootstrap API daemon v{0: <9} |".format(version))
|
||||
print("| Debug: {0: <49} |".format(str(config["debug"])))
|
||||
print("| API version: v{0: <42} |".format(API_VERSION))
|
||||
print(
|
||||
"| Listen: {0: <48} |".format(
|
||||
"{}:{}".format(config["api_address"], config["api_port"])
|
||||
)
|
||||
)
|
||||
print("|----------------------------------------------------------|")
|
||||
print("")
|
||||
|
||||
# Initialize the database
|
||||
db.init_database(config)
|
||||
|
||||
# Initialize the Ansible repository
|
||||
git.init_repository(config)
|
||||
|
||||
# Initialize the tftp root
|
||||
tftp.init_tftp(config)
|
||||
|
||||
if "--init-only" in argv:
|
||||
print("Successfully initialized pvcbootstrapd; exiting.")
|
||||
exit(0)
|
||||
|
||||
# Start DNSMasq
|
||||
dnsmasq = dnsmasqd.DNSMasq(config)
|
||||
dnsmasq.start()
|
||||
|
||||
def cleanup(retcode):
|
||||
dnsmasq.stop()
|
||||
exit(retcode)
|
||||
|
||||
def term(signum="", frame=""):
|
||||
print("Received TERM, exiting.")
|
||||
cleanup(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, term)
|
||||
signal.signal(signal.SIGINT, term)
|
||||
signal.signal(signal.SIGQUIT, term)
|
||||
|
||||
# Start Flask
|
||||
pvcbootstrapd.app.run(
|
||||
config["api_address"],
|
||||
config["api_port"],
|
||||
use_reloader=False,
|
||||
threaded=False,
|
||||
processes=4,
|
||||
)
|
0
bootstrap-daemon/pvcbootstrapd/__init__.py
Normal file
0
bootstrap-daemon/pvcbootstrapd/__init__.py
Normal file
119
bootstrap-daemon/pvcbootstrapd/dnsmasq-lease.py
Executable file
119
bootstrap-daemon/pvcbootstrapd/dnsmasq-lease.py
Executable file
@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# dnsmasq-lease.py - DNSMasq lease interface for pvcnodedprov
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
from os import environ
|
||||
from sys import argv
|
||||
from requests import post
|
||||
from json import dumps
|
||||
|
||||
# Request log
|
||||
# dnsmasq-dhcp[877466]: 2067194916 available DHCP range: 10.199.199.10 -- 10.199.199.19
|
||||
# dnsmasq-dhcp[877466]: 2067194916 DHCPDISCOVER(ens8) 52:54:00:34:36:40
|
||||
# dnsmasq-dhcp[877466]: 2067194916 tags: ens8
|
||||
# dnsmasq-dhcp[877466]: 2067194916 DHCPOFFER(ens8) 10.199.199.14 52:54:00:34:36:40
|
||||
# dnsmasq-dhcp[877466]: 2067194916 requested options: 1:netmask, 28:broadcast, 2:time-offset, 3:router,
|
||||
# dnsmasq-dhcp[877466]: 2067194916 requested options: 15:domain-name, 6:dns-server, 12:hostname
|
||||
# dnsmasq-dhcp[877466]: 2067194916 next server: 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 1 option: 53 message-type 2
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 54 server-identifier 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 51 lease-time 1h
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 58 T1 30m
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 59 T2 52m30s
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 1 netmask 255.255.255.0
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 28 broadcast 10.199.199.255
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 3 router 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 6 dns-server 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 8 option: 15 domain-name test.com
|
||||
# dnsmasq-dhcp[877466]: 2067194916 available DHCP range: 10.199.199.10 -- 10.199.199.19
|
||||
# dnsmasq-dhcp[877466]: 2067194916 DHCPREQUEST(ens8) 10.199.199.14 52:54:00:34:36:40
|
||||
# dnsmasq-dhcp[877466]: 2067194916 tags: ens8
|
||||
# dnsmasq-dhcp[877466]: 2067194916 DHCPACK(ens8) 10.199.199.14 52:54:00:34:36:40
|
||||
# dnsmasq-dhcp[877466]: 2067194916 requested options: 1:netmask, 28:broadcast, 2:time-offset, 3:router,
|
||||
# dnsmasq-dhcp[877466]: 2067194916 requested options: 15:domain-name, 6:dns-server, 12:hostname
|
||||
# dnsmasq-dhcp[877466]: 2067194916 next server: 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 1 option: 53 message-type 5
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 54 server-identifier 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 51 lease-time 1h
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 58 T1 30m
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 59 T2 52m30s
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 1 netmask 255.255.255.0
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 28 broadcast 10.199.199.255
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 3 router 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 4 option: 6 dns-server 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 2067194916 sent size: 8 option: 15 domain-name test.com
|
||||
# dnsmasq-script[877466]: ['/var/home/joshua/dnsmasq-lease.py', 'add', '52:54:00:34:36:40', '10.199.199.14']
|
||||
# dnsmasq-script[877466]: environ({'DNSMASQ_INTERFACE': 'ens8', 'DNSMASQ_LEASE_EXPIRES': '1638422308', 'DNSMASQ_REQUESTED_OPTIONS': '1,28,2,3,15,6,12', 'DNSMASQ_TAGS': 'ens8', 'DNSMASQ_TIME_REMAINING': '3600', 'DNSMASQ_LOG_DHCP': '1', 'LC_CTYPE': 'C.UTF-8'})
|
||||
|
||||
# Renew log
|
||||
# dnsmasq-dhcp[877466]: 1471211555 available DHCP range: 10.199.199.10 -- 10.199.199.19
|
||||
# dnsmasq-dhcp[877466]: 1471211555 DHCPREQUEST(ens8) 10.199.199.14 52:54:00:34:36:40
|
||||
# dnsmasq-dhcp[877466]: 1471211555 tags: ens8
|
||||
# dnsmasq-dhcp[877466]: 1471211555 DHCPACK(ens8) 10.199.199.14 52:54:00:34:36:40
|
||||
# dnsmasq-dhcp[877466]: 1471211555 requested options: 1:netmask, 28:broadcast, 2:time-offset, 3:router,
|
||||
# dnsmasq-dhcp[877466]: 1471211555 requested options: 15:domain-name, 6:dns-server, 12:hostname
|
||||
# dnsmasq-dhcp[877466]: 1471211555 next server: 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 1 option: 53 message-type 5
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 4 option: 54 server-identifier 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 4 option: 51 lease-time 1h
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 4 option: 58 T1 30m
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 4 option: 59 T2 52m30s
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 4 option: 1 netmask 255.255.255.0
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 4 option: 28 broadcast 10.199.199.255
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 4 option: 3 router 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 4 option: 6 dns-server 10.199.199.1
|
||||
# dnsmasq-dhcp[877466]: 1471211555 sent size: 8 option: 15 domain-name test.com
|
||||
# dnsmasq-script[877466]: ['/var/home/joshua/dnsmasq-lease.py', 'old', '52:54:00:34:36:40', '10.199.199.14']
|
||||
# dnsmasq-script[877466]: environ({'DNSMASQ_INTERFACE': 'ens8', 'DNSMASQ_LEASE_EXPIRES': '1638422371', 'DNSMASQ_REQUESTED_OPTIONS': '1,28,2,3,15,6,12', 'DNSMASQ_TAGS': 'ens8', 'DNSMASQ_TIME_REMAINING': '3600', 'DNSMASQ_LOG_DHCP': '1', 'LC_CTYPE': 'C.UTF-8'})
|
||||
|
||||
action = argv[1]
|
||||
|
||||
api_uri = environ.get("API_URI", "http://127.0.0.1:9999/checkin/dnsmasq")
|
||||
api_headers = {"ContentType": "application/json"}
|
||||
|
||||
print(environ)
|
||||
|
||||
if action in ["add"]:
|
||||
macaddr = argv[2]
|
||||
ipaddr = argv[3]
|
||||
api_data = dumps(
|
||||
{
|
||||
"action": action,
|
||||
"macaddr": macaddr,
|
||||
"ipaddr": ipaddr,
|
||||
"hostname": environ.get("DNSMASQ_SUPPLIED_HOSTNAME"),
|
||||
"client_id": environ.get("DNSMASQ_CLIENT_ID"),
|
||||
"expiry": environ.get("DNSMASQ_LEASE_EXPIRES"),
|
||||
"vendor_class": environ.get("DNSMASQ_VENDOR_CLASS"),
|
||||
"user_class": environ.get("DNSMASQ_USER_CLASS0"),
|
||||
}
|
||||
)
|
||||
post(api_uri, headers=api_headers, data=api_data, verify=False)
|
||||
|
||||
elif action in ["tftp"]:
|
||||
size = argv[2]
|
||||
destaddr = argv[3]
|
||||
filepath = argv[4]
|
||||
api_data = dumps(
|
||||
{"action": action, "size": size, "destaddr": destaddr, "filepath": filepath}
|
||||
)
|
||||
post(api_uri, headers=api_headers, data=api_data, verify=False)
|
||||
|
||||
exit(0)
|
241
bootstrap-daemon/pvcbootstrapd/flaskapi.py
Executable file
241
bootstrap-daemon/pvcbootstrapd/flaskapi.py
Executable file
@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# pvcbootstrapd.py - PVC Cluster Auto-bootstrap
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import flask
|
||||
import json
|
||||
|
||||
from pvcbootstrapd.Daemon import config
|
||||
|
||||
import pvcbootstrapd.lib.lib as lib
|
||||
|
||||
from flask_restful import Resource, Api
|
||||
from celery import Celery
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
# Create Flask app and set config values
|
||||
app = flask.Flask(__name__)
|
||||
blueprint = flask.Blueprint("api", __name__, url_prefix="")
|
||||
api = Api(blueprint)
|
||||
app.register_blueprint(blueprint)
|
||||
|
||||
app.config[
|
||||
"CELERY_BROKER_URL"
|
||||
] = f"redis://{config['queue_address']}:{config['queue_port']}{config['queue_path']}"
|
||||
|
||||
celery = Celery(app.name, broker=app.config["CELERY_BROKER_URL"])
|
||||
celery.conf.update(app.config)
|
||||
|
||||
|
||||
#
|
||||
# Celery functions
|
||||
#
|
||||
@celery.task(bind=True)
|
||||
def dnsmasq_checkin(self, data):
|
||||
lib.dnsmasq_checkin(config, data)
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
def host_checkin(self, data):
|
||||
lib.host_checkin(config, data)
|
||||
|
||||
|
||||
#
|
||||
# API routes
|
||||
#
|
||||
class API_Root(Resource):
|
||||
def get(self):
|
||||
"""
|
||||
Return basic details of the API
|
||||
---
|
||||
tags:
|
||||
- root
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
schema:
|
||||
type: object
|
||||
id: Message
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: A text message describing the result
|
||||
example: "The foo was successfully maxed"
|
||||
"""
|
||||
return {"message": "pvcbootstrapd API"}, 200
|
||||
|
||||
|
||||
api.add_resource(API_Root, "/")
|
||||
|
||||
|
||||
class API_Checkin(Resource):
|
||||
def get(self):
|
||||
"""
|
||||
Return checkin details of the API
|
||||
---
|
||||
tags:
|
||||
- checkin
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
schema:
|
||||
type: object
|
||||
id: Message
|
||||
"""
|
||||
return {"message": "pvcbootstrapd API Checkin interface"}, 200
|
||||
|
||||
|
||||
api.add_resource(API_Checkin, "/checkin")
|
||||
|
||||
|
||||
class API_Checkin_DNSMasq(Resource):
|
||||
def post(self):
|
||||
"""
|
||||
Register a checkin from the DNSMasq subsystem
|
||||
---
|
||||
tags:
|
||||
- checkin
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- in: body
|
||||
name: dnsmasq_checkin_event
|
||||
description: An event checkin from an external bootstrap tool component.
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- action
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
description: The action of the event.
|
||||
example: "add"
|
||||
macaddr:
|
||||
type: string
|
||||
description: (add, old) The MAC address from a DHCP request.
|
||||
example: "ff:ff:ff:ab:cd:ef"
|
||||
ipaddr:
|
||||
type: string
|
||||
description: (add, old) The IP address from a DHCP request.
|
||||
example: "10.199.199.10"
|
||||
hostname:
|
||||
type: string
|
||||
description: (add, old) The client hostname from a DHCP request.
|
||||
example: "pvc-installer-live"
|
||||
client_id:
|
||||
type: string
|
||||
description: (add, old) The client ID from a DHCP request.
|
||||
example: "01:ff:ff:ff:ab:cd:ef"
|
||||
vendor_class:
|
||||
type: string
|
||||
description: (add, old) The DHCP vendor-class option from a DHCP request.
|
||||
example: "CPQRIB3 (HP Proliant DL360 G6 iLO)"
|
||||
user_class:
|
||||
type: string
|
||||
description: (add, old) The DHCP user-class option from a DHCP request.
|
||||
example: None
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
schema:
|
||||
type: object
|
||||
id: Message
|
||||
"""
|
||||
try:
|
||||
data = json.loads(flask.request.data)
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
data = {"action": None}
|
||||
logger.info(f"Handling DNSMasq checkin for: {data}")
|
||||
|
||||
task = dnsmasq_checkin.delay(data)
|
||||
logger.debug(task)
|
||||
return {"message": "received checkin from DNSMasq"}, 200
|
||||
|
||||
|
||||
api.add_resource(API_Checkin_DNSMasq, "/checkin/dnsmasq")
|
||||
|
||||
|
||||
class API_Checkin_Host(Resource):
|
||||
def post(self):
|
||||
"""
|
||||
Register a checkin from the Host subsystem
|
||||
---
|
||||
tags:
|
||||
- checkin
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- in: body
|
||||
name: host_checkin_event
|
||||
description: An event checkin from an external bootstrap tool component.
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- action
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
description: The action of the event.
|
||||
example: "begin"
|
||||
hostname:
|
||||
type: string
|
||||
description: The system hostname.
|
||||
example: "hv1.mydomain.tld"
|
||||
host_macaddr:
|
||||
type: string
|
||||
description: The MAC address of the system provisioning interface.
|
||||
example: "ff:ff:ff:ab:cd:ef"
|
||||
host_ipaddr:
|
||||
type: string
|
||||
description: The IP address of the system provisioning interface.
|
||||
example: "10.199.199.11"
|
||||
bmc_macaddr:
|
||||
type: string
|
||||
description: The MAC address of the system BMC interface.
|
||||
example: "ff:ff:ff:01:23:45"
|
||||
bmc_ipaddr:
|
||||
type: string
|
||||
description: The IP addres of the system BMC interface.
|
||||
example: "10.199.199.10"
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
schema:
|
||||
type: object
|
||||
id: Message
|
||||
"""
|
||||
try:
|
||||
data = json.loads(flask.request.data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid JSON data, setting action to None: {e}")
|
||||
data = {"action": None}
|
||||
logger.info(f"Handling Host checkin for: {data}")
|
||||
|
||||
task = host_checkin.delay(data)
|
||||
logger.debug(task)
|
||||
return {"message": "received checkin from Host"}, 200
|
||||
|
||||
|
||||
api.add_resource(API_Checkin_Host, "/checkin/host")
|
0
bootstrap-daemon/pvcbootstrapd/lib/__init__.py
Normal file
0
bootstrap-daemon/pvcbootstrapd/lib/__init__.py
Normal file
78
bootstrap-daemon/pvcbootstrapd/lib/ansible.py
Executable file
78
bootstrap-daemon/pvcbootstrapd/lib/ansible.py
Executable file
@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# ansible.py - PVC Cluster Auto-bootstrap Ansible libraries
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import pvcbootstrapd.lib.git as git
|
||||
|
||||
import ansible_runner
|
||||
import tempfile
|
||||
|
||||
from time import sleep
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def run_bootstrap(config, cspec, cluster, nodes):
|
||||
"""
|
||||
Run an Ansible bootstrap against a cluster
|
||||
"""
|
||||
logger.debug(nodes)
|
||||
|
||||
# Construct our temporary INI inventory string
|
||||
logger.info("Constructing virtual Ansible inventory")
|
||||
base_yaml = cspec["clusters"][cluster.name]["base_yaml"]
|
||||
local_domain = base_yaml.get("local_domain")
|
||||
inventory = [f"""[{cluster.name}]"""]
|
||||
for node in nodes:
|
||||
inventory.append(
|
||||
f"""{node.name}.{local_domain} ansible_host={node.host_ipaddr}"""
|
||||
)
|
||||
inventory = "\n".join(inventory)
|
||||
logger.debug(inventory)
|
||||
|
||||
# Waiting 30 seconds to ensure everything is booted an stabilized
|
||||
logger.info("Waiting 60s before starting Ansible bootstrap.")
|
||||
sleep(60)
|
||||
|
||||
# Run the Ansible playbooks
|
||||
with tempfile.TemporaryDirectory(prefix="pvc-ansible-bootstrap_") as pdir:
|
||||
try:
|
||||
r = ansible_runner.run(
|
||||
private_data_dir=f"{pdir}",
|
||||
inventory=inventory,
|
||||
limit=f"{cluster.name}",
|
||||
playbook=f"{config['ansible_path']}/pvc.yml",
|
||||
extravars={
|
||||
"ansible_ssh_private_key_file": config["ansible_keyfile"],
|
||||
"bootstrap": "yes",
|
||||
},
|
||||
forks=len(nodes),
|
||||
verbosity=2,
|
||||
)
|
||||
logger.info("Final status:")
|
||||
logger.info("{}: {}".format(r.status, r.rc))
|
||||
logger.info(r.stats)
|
||||
if r.rc == 0:
|
||||
git.commit_repository(config)
|
||||
git.push_repository(config)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error: {e}")
|
50
bootstrap-daemon/pvcbootstrapd/lib/dataclasses.py
Executable file
50
bootstrap-daemon/pvcbootstrapd/lib/dataclasses.py
Executable file
@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# dataclasses.py - PVC Cluster Auto-bootstrap dataclasses
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cluster:
|
||||
"""
|
||||
An instance of a Cluster
|
||||
"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
state: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
"""
|
||||
An instance of a Node
|
||||
"""
|
||||
|
||||
id: int
|
||||
cluster: str
|
||||
state: str
|
||||
name: str
|
||||
nid: int
|
||||
bmc_macaddr: str
|
||||
bmc_iapddr: str
|
||||
host_macaddr: str
|
||||
host_ipaddr: str
|
267
bootstrap-daemon/pvcbootstrapd/lib/db.py
Executable file
267
bootstrap-daemon/pvcbootstrapd/lib/db.py
Executable file
@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# db.py - PVC Cluster Auto-bootstrap database libraries
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import contextlib
|
||||
|
||||
from pvcbootstrapd.lib.dataclasses import Cluster, Node
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
#
|
||||
# Database functions
|
||||
#
|
||||
@contextlib.contextmanager
|
||||
def dbconn(db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA foreign_keys = 1")
|
||||
cur = conn.cursor()
|
||||
yield cur
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_database(config):
|
||||
db_path = config["database_path"]
|
||||
if not os.path.isfile(db_path):
|
||||
print("First run: initializing database.")
|
||||
# Initializing the database
|
||||
with dbconn(db_path) as cur:
|
||||
# Table listing all clusters
|
||||
cur.execute(
|
||||
"""CREATE TABLE clusters
|
||||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
state TEXT NOT NULL)"""
|
||||
)
|
||||
# Table listing all nodes
|
||||
# FK: cluster -> clusters.id
|
||||
cur.execute(
|
||||
"""CREATE TABLE nodes
|
||||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cluster INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
nodeid INTEGER NOT NULL,
|
||||
bmc_macaddr TEXT NOT NULL,
|
||||
bmc_ipaddr TEXT NOT NULL,
|
||||
host_macaddr TEXT NOT NULL,
|
||||
host_ipaddr TEXT NOT NULL,
|
||||
CONSTRAINT cluster_col FOREIGN KEY (cluster) REFERENCES clusters(id) ON DELETE CASCADE )"""
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Cluster functions
|
||||
#
|
||||
def get_cluster(config, cid=None, name=None):
|
||||
if cid is None and name is None:
|
||||
return None
|
||||
elif cid is not None:
|
||||
findfield = "id"
|
||||
datafield = cid
|
||||
elif name is not None:
|
||||
findfield = "name"
|
||||
datafield = name
|
||||
|
||||
with dbconn(config["database_path"]) as cur:
|
||||
cur.execute(f"""SELECT * FROM clusters WHERE {findfield} = ?""", (datafield,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
if len(rows) > 0:
|
||||
row = rows[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
return Cluster(row[0], row[1], row[2])
|
||||
|
||||
|
||||
def add_cluster(config, cspec, name, state):
|
||||
with dbconn(config["database_path"]) as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO clusters
|
||||
(name, state)
|
||||
VALUES
|
||||
(?, ?)""",
|
||||
(name, state),
|
||||
)
|
||||
|
||||
logger.info(f"New cluster {name} added, populating bootstrap nodes from cspec")
|
||||
for bmcmac in cspec["clusters"][name]["cspec_yaml"]["bootstrap"]:
|
||||
hostname = cspec["clusters"][name]["cspec_yaml"]["bootstrap"][bmcmac]["node"][
|
||||
"hostname"
|
||||
]
|
||||
add_node(
|
||||
config,
|
||||
name,
|
||||
hostname,
|
||||
int("".join(filter(str.isdigit, hostname))),
|
||||
"init",
|
||||
bmcmac,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
logger.info(f"Added node {hostname}")
|
||||
|
||||
return get_cluster(config, name=name)
|
||||
|
||||
|
||||
def update_cluster_state(config, name, state):
|
||||
with dbconn(config["database_path"]) as cur:
|
||||
cur.execute(
|
||||
"""UPDATE clusters
|
||||
SET state = ?
|
||||
WHERE name = ?""",
|
||||
(state, name),
|
||||
)
|
||||
|
||||
return get_cluster(config, name=name)
|
||||
|
||||
|
||||
#
|
||||
# Node functions
|
||||
#
|
||||
def get_node(config, cluster_name, nid=None, name=None, bmc_macaddr=None):
|
||||
cluster = get_cluster(config, name=cluster_name)
|
||||
|
||||
if nid is None and name is None and bmc_macaddr is None:
|
||||
return None
|
||||
elif nid is not None:
|
||||
findfield = "id"
|
||||
datafield = nid
|
||||
elif bmc_macaddr is not None:
|
||||
findfield = "bmc_macaddr"
|
||||
datafield = bmc_macaddr
|
||||
elif name is not None:
|
||||
findfield = "name"
|
||||
datafield = name
|
||||
|
||||
with dbconn(config["database_path"]) as cur:
|
||||
cur.execute(
|
||||
f"""SELECT * FROM nodes WHERE {findfield} = ? AND cluster = ?""",
|
||||
(datafield, cluster.id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if len(rows) > 0:
|
||||
row = rows[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
return Node(
|
||||
row[0], cluster.name, row[2], row[3], row[4], row[5], row[6], row[7], row[8]
|
||||
)
|
||||
|
||||
|
||||
def get_nodes_in_cluster(config, cluster_name):
|
||||
cluster = get_cluster(config, name=cluster_name)
|
||||
|
||||
with dbconn(config["database_path"]) as cur:
|
||||
cur.execute("""SELECT * FROM nodes WHERE cluster = ?""", (cluster.id,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
node_list = list()
|
||||
for row in rows:
|
||||
node_list.append(
|
||||
Node(
|
||||
row[0],
|
||||
cluster.name,
|
||||
row[2],
|
||||
row[3],
|
||||
row[4],
|
||||
row[5],
|
||||
row[6],
|
||||
row[7],
|
||||
row[8],
|
||||
)
|
||||
)
|
||||
|
||||
return node_list
|
||||
|
||||
|
||||
def add_node(
|
||||
config,
|
||||
cluster_name,
|
||||
name,
|
||||
nodeid,
|
||||
state,
|
||||
bmc_macaddr,
|
||||
bmc_ipaddr,
|
||||
host_macaddr,
|
||||
host_ipaddr,
|
||||
):
|
||||
cluster = get_cluster(config, name=cluster_name)
|
||||
|
||||
with dbconn(config["database_path"]) as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO nodes
|
||||
(cluster, state, name, nodeid, bmc_macaddr, bmc_ipaddr, host_macaddr, host_ipaddr)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
cluster.id,
|
||||
state,
|
||||
name,
|
||||
nodeid,
|
||||
bmc_macaddr,
|
||||
bmc_ipaddr,
|
||||
host_macaddr,
|
||||
host_ipaddr,
|
||||
),
|
||||
)
|
||||
|
||||
return get_node(config, cluster_name, name=name)
|
||||
|
||||
|
||||
def update_node_state(config, cluster_name, name, state):
|
||||
cluster = get_cluster(config, name=cluster_name)
|
||||
|
||||
with dbconn(config["database_path"]) as cur:
|
||||
cur.execute(
|
||||
"""UPDATE nodes
|
||||
SET state = ?
|
||||
WHERE name = ? AND cluster = ?""",
|
||||
(state, name, cluster.id),
|
||||
)
|
||||
|
||||
return get_node(config, cluster_name, name=name)
|
||||
|
||||
|
||||
def update_node_addresses(
|
||||
config, cluster_name, name, bmc_macaddr, bmc_ipaddr, host_macaddr, host_ipaddr
|
||||
):
|
||||
cluster = get_cluster(config, name=cluster_name)
|
||||
|
||||
with dbconn(config["database_path"]) as cur:
|
||||
cur.execute(
|
||||
"""UPDATE nodes
|
||||
SET bmc_macaddr = ?, bmc_ipaddr = ?, host_macaddr = ?, host_ipaddr = ?
|
||||
WHERE name = ? AND cluster = ?""",
|
||||
(bmc_macaddr, bmc_ipaddr, host_macaddr, host_ipaddr, name, cluster.id),
|
||||
)
|
||||
|
||||
return get_node(config, cluster_name, name=name)
|
99
bootstrap-daemon/pvcbootstrapd/lib/dnsmasq.py
Executable file
99
bootstrap-daemon/pvcbootstrapd/lib/dnsmasq.py
Executable file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# dnsmasq.py - PVC Cluster Auto-bootstrap DNSMasq instance
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import signal
|
||||
|
||||
from threading import Thread
|
||||
|
||||
|
||||
class DNSMasq:
|
||||
"""
|
||||
Implementes a daemonized instance of DNSMasq for providing DHCP and TFTP services
|
||||
|
||||
The DNSMasq instance listens on the configured 'dhcp_address', and instead of a "real"
|
||||
leases database forwards requests to the 'dnsmasq-lease.py' script. This script will
|
||||
then hit the pvcbootstrapd '/checkin' API endpoint to perform actions.
|
||||
|
||||
TFTP is provided to automate the bootstrap of a node, providing the pvc-installer
|
||||
over TFTP as well as a seed configuration which is created by the API.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.environment = {
|
||||
"API_URI": f"http://{config['api_address']}:{config['api_port']}/checkin/dnsmasq"
|
||||
}
|
||||
self.dnsmasq_cmd = [
|
||||
"/usr/sbin/dnsmasq",
|
||||
"--bogus-priv",
|
||||
"--no-hosts",
|
||||
"--dhcp-authoritative",
|
||||
"--filterwin2k",
|
||||
"--expand-hosts",
|
||||
"--domain-needed",
|
||||
f"--domain={config['dhcp_domain']}",
|
||||
f"--local=/{config['dhcp_domain']}/",
|
||||
"--log-facility=-",
|
||||
"--log-dhcp",
|
||||
"--keep-in-foreground",
|
||||
f"--dhcp-script={os.getcwd()}/pvcbootstrapd/dnsmasq-lease.py",
|
||||
"--bind-interfaces",
|
||||
f"--listen-address={config['dhcp_address']}",
|
||||
f"--dhcp-option=3,{config['dhcp_gateway']}",
|
||||
f"--dhcp-range={config['dhcp_lease_start']},{config['dhcp_lease_end']},{config['dhcp_lease_time']}",
|
||||
"--enable-tftp",
|
||||
f"--tftp-root={config['tftp_root_path']}/",
|
||||
# This block of dhcp-match, tag-if, and dhcp-boot statements sets the following TFTP setup:
|
||||
# If the machine sends client-arch 0, and is not tagged iPXE, load undionly.kpxe (chainload)
|
||||
# If the machine sends client-arch 7 or 9, and is not tagged iPXE, load ipxe.efi (chainload)
|
||||
# If the machine sends the iPXE option, load boot.ipxe (iPXE boot configuration)
|
||||
"--dhcp-match=set:o_bios,option:client-arch,0",
|
||||
"--dhcp-match=set:o_uefi,option:client-arch,7",
|
||||
"--dhcp-match=set:o_uefi,option:client-arch,9",
|
||||
"--dhcp-match=set:ipxe,175",
|
||||
"--tag-if=set:bios,tag:!ipxe,tag:o_bios",
|
||||
"--tag-if=set:uefi,tag:!ipxe,tag:o_uefi",
|
||||
"--dhcp-boot=tag:bios,undionly.kpxe",
|
||||
"--dhcp-boot=tag:uefi,ipxe.efi",
|
||||
"--dhcp-boot=tag:ipxe,boot.ipxe",
|
||||
]
|
||||
if config["debug"]:
|
||||
self.dnsmasq_cmd.append("--leasefile-ro")
|
||||
|
||||
print(self.dnsmasq_cmd)
|
||||
self.stdout = subprocess.PIPE
|
||||
|
||||
def execute(self):
|
||||
self.proc = subprocess.Popen(
|
||||
self.dnsmasq_cmd,
|
||||
env=self.environment,
|
||||
)
|
||||
|
||||
def start(self):
|
||||
self.thread = Thread(target=self.execute, args=())
|
||||
self.thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.proc.send_signal(signal.SIGTERM)
|
||||
|
||||
def reload(self):
|
||||
self.proc.send_signal(signal.SIGHUP)
|
213
bootstrap-daemon/pvcbootstrapd/lib/git.py
Executable file
213
bootstrap-daemon/pvcbootstrapd/lib/git.py
Executable file
@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# git.py - PVC Cluster Auto-bootstrap Git repository libraries
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import os.path
|
||||
import git
|
||||
import yaml
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def init_repository(config):
|
||||
"""
|
||||
Clone the Ansible git repository
|
||||
"""
|
||||
try:
|
||||
git_ssh_cmd = f"ssh -i {config['ansible_keyfile']} -o StrictHostKeyChecking=no"
|
||||
if not os.path.exists(config["ansible_path"]):
|
||||
print(
|
||||
f"First run: cloning repository {config['ansible_remote']} branch {config['ansible_branch']} to {config['ansible_path']}"
|
||||
)
|
||||
git.Repo.clone_from(
|
||||
config["ansible_remote"],
|
||||
config["ansible_path"],
|
||||
branch=config["ansible_branch"],
|
||||
env=dict(GIT_SSH_COMMAND=git_ssh_cmd),
|
||||
)
|
||||
|
||||
g = git.cmd.Git(f"{config['ansible_path']}")
|
||||
g.checkout(config["ansible_branch"])
|
||||
g.submodule("update", "--init", env=dict(GIT_SSH_COMMAND=git_ssh_cmd))
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
def pull_repository(config):
|
||||
"""
|
||||
Pull (with rebase) the Ansible git repository
|
||||
"""
|
||||
logger.info(f"Updating local configuration repository {config['ansible_path']}")
|
||||
try:
|
||||
git_ssh_cmd = f"ssh -i {config['ansible_keyfile']} -o StrictHostKeyChecking=no"
|
||||
g = git.cmd.Git(f"{config['ansible_path']}")
|
||||
g.pull(rebase=True, env=dict(GIT_SSH_COMMAND=git_ssh_cmd))
|
||||
g.submodule("update", "--init", env=dict(GIT_SSH_COMMAND=git_ssh_cmd))
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
|
||||
|
||||
def commit_repository(config):
|
||||
"""
|
||||
Commit uncommitted changes to the Ansible git repository
|
||||
"""
|
||||
logger.info(
|
||||
f"Committing changes to local configuration repository {config['ansible_path']}"
|
||||
)
|
||||
|
||||
try:
|
||||
g = git.cmd.Git(f"{config['ansible_path']}")
|
||||
g.add("--all")
|
||||
commit_env = {
|
||||
"GIT_COMMITTER_NAME": "PVC Bootstrap",
|
||||
"GIT_COMMITTER_EMAIL": "git@pvcbootstrapd",
|
||||
}
|
||||
g.commit(
|
||||
"-m",
|
||||
"Automated commit from PVC Bootstrap Ansible subsystem",
|
||||
author="PVC Bootstrap <git@pvcbootstrapd>",
|
||||
env=commit_env,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
|
||||
|
||||
def push_repository(config):
|
||||
"""
|
||||
Push changes to the default remote
|
||||
"""
|
||||
logger.info(
|
||||
f"Pushing changes from local configuration repository {config['ansible_path']}"
|
||||
)
|
||||
|
||||
try:
|
||||
git_ssh_cmd = f"ssh -i {config['ansible_keyfile']} -o StrictHostKeyChecking=no"
|
||||
g = git.Repo(f"{config['ansible_path']}")
|
||||
origin = g.remote(name="origin")
|
||||
origin.push(env=dict(GIT_SSH_COMMAND=git_ssh_cmd))
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
|
||||
|
||||
def load_cspec_yaml(config):
|
||||
"""
|
||||
Load the bootstrap group_vars for all known clusters
|
||||
"""
|
||||
# Pull down the repository
|
||||
pull_repository(config)
|
||||
|
||||
# Load our clusters file and read the clusters from it
|
||||
clusters_file = f"{config['ansible_path']}/{config['ansible_clusters_file']}"
|
||||
logger.info(f"Loading cluster configuration from file '{clusters_file}'")
|
||||
with open(clusters_file, "r") as clustersfh:
|
||||
clusters = yaml.load(clustersfh, Loader=yaml.SafeLoader).get("clusters", list())
|
||||
|
||||
# Define a base cpec
|
||||
cspec = {
|
||||
"bootstrap": dict(),
|
||||
"hooks": dict(),
|
||||
"clusters": dict(),
|
||||
}
|
||||
|
||||
# Read each cluster's cspec and update the base cspec
|
||||
logger.info("Loading per-cluster specifications")
|
||||
for cluster in clusters:
|
||||
cspec["clusters"][cluster] = dict()
|
||||
cspec["clusters"][cluster]["bootstrap_nodes"] = list()
|
||||
|
||||
cspec_file = f"{config['ansible_path']}/group_vars/{cluster}/{config['ansible_cspec_files_bootstrap']}"
|
||||
if os.path.exists(cspec_file):
|
||||
with open(cspec_file, "r") as cpsecfh:
|
||||
try:
|
||||
cspec_yaml = yaml.load(cpsecfh, Loader=yaml.SafeLoader)
|
||||
except Exception as e:
|
||||
logger.warn(
|
||||
f"Failed to load {config['ansible_cspec_files_bootstrap']} for cluster {cluster}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
cspec["clusters"][cluster]["cspec_yaml"] = cspec_yaml
|
||||
|
||||
# Convert the MAC address keys to lowercase
|
||||
# DNSMasq operates with lowercase keys, but often these are written with uppercase.
|
||||
# Convert them to lowercase to prevent discrepancies later on.
|
||||
cspec_yaml["bootstrap"] = {
|
||||
k.lower(): v for k, v in cspec_yaml["bootstrap"].items()
|
||||
}
|
||||
|
||||
# Load in the YAML for the cluster
|
||||
base_yaml = load_base_yaml(config, cluster)
|
||||
cspec["clusters"][cluster]["base_yaml"] = base_yaml
|
||||
pvc_yaml = load_pvc_yaml(config, cluster)
|
||||
cspec["clusters"][cluster]["pvc_yaml"] = pvc_yaml
|
||||
|
||||
# Set per-node values from elsewhere
|
||||
for node in cspec_yaml["bootstrap"]:
|
||||
cspec["clusters"][cluster]["bootstrap_nodes"].append(
|
||||
cspec_yaml["bootstrap"][node]["node"]["hostname"]
|
||||
)
|
||||
|
||||
# Set the cluster value automatically
|
||||
cspec_yaml["bootstrap"][node]["node"]["cluster"] = cluster
|
||||
|
||||
# Set the domain value automatically via base config
|
||||
cspec_yaml["bootstrap"][node]["node"]["domain"] = base_yaml[
|
||||
"local_domain"
|
||||
]
|
||||
|
||||
# Set the node FQDN value automatically
|
||||
cspec_yaml["bootstrap"][node]["node"][
|
||||
"fqdn"
|
||||
] = f"{cspec_yaml['bootstrap'][node]['node']['hostname']}.{cspec_yaml['bootstrap'][node]['node']['domain']}"
|
||||
|
||||
# Append bootstrap entries to the main dictionary
|
||||
cspec["bootstrap"] = {**cspec["bootstrap"], **cspec_yaml["bootstrap"]}
|
||||
|
||||
# Append hooks to the main dictionary (per-cluster)
|
||||
if cspec_yaml.get("hooks"):
|
||||
cspec["hooks"][cluster] = cspec_yaml["hooks"]
|
||||
|
||||
logger.info("Finished loading per-cluster specifications")
|
||||
return cspec
|
||||
|
||||
|
||||
def load_base_yaml(config, cluster):
|
||||
"""
|
||||
Load the base.yml group_vars for a cluster
|
||||
"""
|
||||
base_file = f"{config['ansible_path']}/group_vars/{cluster}/{config['ansible_cspec_files_base']}"
|
||||
with open(base_file, "r") as varsfile:
|
||||
base_yaml = yaml.load(varsfile, Loader=yaml.SafeLoader)
|
||||
|
||||
return base_yaml
|
||||
|
||||
|
||||
def load_pvc_yaml(config, cluster):
|
||||
"""
|
||||
Load the pvc.yml group_vars for a cluster
|
||||
"""
|
||||
pvc_file = f"{config['ansible_path']}/group_vars/{cluster}/{config['ansible_cspec_files_pvc']}"
|
||||
with open(pvc_file, "r") as varsfile:
|
||||
pvc_yaml = yaml.load(varsfile, Loader=yaml.SafeLoader)
|
||||
|
||||
return pvc_yaml
|
316
bootstrap-daemon/pvcbootstrapd/lib/hooks.py
Executable file
316
bootstrap-daemon/pvcbootstrapd/lib/hooks.py
Executable file
@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# hooks.py - PVC Cluster Auto-bootstrap Hook libraries
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import pvcbootstrapd.lib.db as db
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import paramiko
|
||||
import contextlib
|
||||
import requests
|
||||
|
||||
from re import match
|
||||
from time import sleep
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def run_paramiko(config, node_address):
|
||||
ssh_client = paramiko.SSHClient()
|
||||
ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh_client.connect(
|
||||
hostname=node_address,
|
||||
username=config["deploy_username"],
|
||||
key_filename=config["ansible_keyfile"],
|
||||
)
|
||||
yield ssh_client
|
||||
ssh_client.close()
|
||||
|
||||
|
||||
def run_hook_osddb(config, targets, args):
|
||||
"""
|
||||
Add an OSD DB defined by args['disk']
|
||||
"""
|
||||
for node in targets:
|
||||
node_name = node.name
|
||||
node_address = node.host_ipaddr
|
||||
|
||||
device = args["disk"]
|
||||
|
||||
logger.info(f"Creating OSD DB on node {node_name} device {device}")
|
||||
|
||||
# Using a direct command on the target here is somewhat messy, but avoids many
|
||||
# complexities of determining a valid API listen address, etc.
|
||||
pvc_cmd_string = f"pvc storage osd create-db-vg --yes {node_name} {device}"
|
||||
|
||||
with run_paramiko(config, node_address) as c:
|
||||
stdin, stdout, stderr = c.exec_command(pvc_cmd_string)
|
||||
logger.debug(stdout.readlines())
|
||||
logger.debug(stderr.readlines())
|
||||
|
||||
|
||||
def run_hook_osd(config, targets, args):
|
||||
"""
|
||||
Add an OSD defined by args['disk'] with weight args['weight']
|
||||
"""
|
||||
for node in targets:
|
||||
node_name = node.name
|
||||
node_address = node.host_ipaddr
|
||||
|
||||
device = args["disk"]
|
||||
weight = args.get("weight", 1)
|
||||
ext_db_flag = args.get("ext_db", False)
|
||||
ext_db_ratio = args.get("ext_db_ratio", 0.05)
|
||||
|
||||
logger.info(f"Creating OSD on node {node_name} device {device} weight {weight}")
|
||||
|
||||
# Using a direct command on the target here is somewhat messy, but avoids many
|
||||
# complexities of determining a valid API listen address, etc.
|
||||
pvc_cmd_string = (
|
||||
f"pvc storage osd add --yes {node_name} {device} --weight {weight}"
|
||||
)
|
||||
if ext_db_flag:
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --ext-db --ext-db-ratio {ext_db_ratio}"
|
||||
|
||||
with run_paramiko(config, node_address) as c:
|
||||
stdin, stdout, stderr = c.exec_command(pvc_cmd_string)
|
||||
logger.debug(stdout.readlines())
|
||||
logger.debug(stderr.readlines())
|
||||
|
||||
|
||||
def run_hook_pool(config, targets, args):
|
||||
"""
|
||||
Add an pool defined by args['name'] on device tier args['tier']
|
||||
"""
|
||||
for node in targets:
|
||||
node_name = node.name
|
||||
node_address = node.host_ipaddr
|
||||
|
||||
name = args["name"]
|
||||
pgs = args.get("pgs", "64")
|
||||
tier = args.get("tier", "default") # Does nothing yet
|
||||
|
||||
logger.info(
|
||||
f"Creating storage pool on node {node_name} name {name} pgs {pgs} tier {tier}"
|
||||
)
|
||||
|
||||
# Using a direct command on the target here is somewhat messy, but avoids many
|
||||
# complexities of determining a valid API listen address, etc.
|
||||
pvc_cmd_string = f"pvc storage pool add {name} {pgs}"
|
||||
|
||||
with run_paramiko(config, node_address) as c:
|
||||
stdin, stdout, stderr = c.exec_command(pvc_cmd_string)
|
||||
logger.debug(stdout.readlines())
|
||||
logger.debug(stderr.readlines())
|
||||
|
||||
# This only runs once on whatever the first node is
|
||||
break
|
||||
|
||||
|
||||
def run_hook_network(config, targets, args):
|
||||
"""
|
||||
Add an network defined by args (many)
|
||||
"""
|
||||
for node in targets:
|
||||
node_name = node.name
|
||||
node_address = node.host_ipaddr
|
||||
|
||||
vni = args["vni"]
|
||||
description = args["description"]
|
||||
nettype = args["type"]
|
||||
mtu = args.get("mtu", None)
|
||||
|
||||
pvc_cmd_string = (
|
||||
f"pvc network add {vni} --description {description} --type {nettype}"
|
||||
)
|
||||
|
||||
if mtu is not None and mtu not in ["auto", "default"]:
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --mtu {mtu}"
|
||||
|
||||
if nettype == "managed":
|
||||
domain = args["domain"]
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --domain {domain}"
|
||||
|
||||
dns_servers = args.get("dns_servers", [])
|
||||
for dns_server in dns_servers:
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --dns-server {dns_server}"
|
||||
|
||||
is_ip4 = args["ip4"]
|
||||
if is_ip4:
|
||||
ip4_network = args["ip4_network"]
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --ipnet {ip4_network}"
|
||||
|
||||
ip4_gateway = args["ip4_gateway"]
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --gateway {ip4_gateway}"
|
||||
|
||||
ip4_dhcp = args["ip4_dhcp"]
|
||||
if ip4_dhcp:
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --dhcp"
|
||||
ip4_dhcp_start = args["ip4_dhcp_start"]
|
||||
ip4_dhcp_end = args["ip4_dhcp_end"]
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --dhcp-start {ip4_dhcp_start} --dhcp-end {ip4_dhcp_end}"
|
||||
else:
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --no-dhcp"
|
||||
|
||||
is_ip6 = args["ip6"]
|
||||
if is_ip6:
|
||||
ip6_network = args["ip6_network"]
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --ipnet6 {ip6_network}"
|
||||
|
||||
ip6_gateway = args["ip6_gateway"]
|
||||
pvc_cmd_string = f"{pvc_cmd_string} --gateway6 {ip6_gateway}"
|
||||
|
||||
logger.info(f"Creating network on node {node_name} VNI {vni} type {nettype}")
|
||||
|
||||
with run_paramiko(config, node_address) as c:
|
||||
stdin, stdout, stderr = c.exec_command(pvc_cmd_string)
|
||||
logger.debug(stdout.readlines())
|
||||
logger.debug(stderr.readlines())
|
||||
|
||||
# This only runs once on whatever the first node is
|
||||
break
|
||||
|
||||
|
||||
def run_hook_script(config, targets, args):
|
||||
"""
|
||||
Run a script on the targets
|
||||
"""
|
||||
for node in targets:
|
||||
node_name = node.name
|
||||
node_address = node.host_ipaddr
|
||||
|
||||
script = args.get("script", None)
|
||||
source = args.get("source", None)
|
||||
path = args.get("path", None)
|
||||
|
||||
logger.info(f"Running script on node {node_name}")
|
||||
|
||||
with run_paramiko(config, node_address) as c:
|
||||
if script is not None:
|
||||
remote_path = "/tmp/pvcbootstrapd.hook"
|
||||
with tempfile.NamedTemporaryFile(mode="w") as tf:
|
||||
tf.write(script)
|
||||
tf.seek(0)
|
||||
|
||||
# Send the file to the remote system
|
||||
tc = c.open_sftp()
|
||||
tc.put(tf.name, remote_path)
|
||||
tc.chmod(remote_path, 0o755)
|
||||
tc.close()
|
||||
elif source == "local":
|
||||
if not match(r"^/", path):
|
||||
path = config["ansible_path"] + "/" + path
|
||||
|
||||
remote_path = "/tmp/pvcbootstrapd.hook"
|
||||
if path is None:
|
||||
continue
|
||||
|
||||
tc = c.open_sftp()
|
||||
tc.put(path, remote_path)
|
||||
tc.chmod(remote_path, 0o755)
|
||||
tc.close()
|
||||
elif source == "remote":
|
||||
remote_path = path
|
||||
|
||||
stdin, stdout, stderr = c.exec_command(remote_path)
|
||||
logger.debug(stdout.readlines())
|
||||
logger.debug(stderr.readlines())
|
||||
|
||||
|
||||
def run_hook_webhook(config, targets, args):
|
||||
"""
|
||||
Send an HTTP requests (no targets)
|
||||
"""
|
||||
logger.info(f"Running webhook against {args['uri']}")
|
||||
|
||||
# Get the body data
|
||||
data = json.dumps(args["body"])
|
||||
headers = {"content-type": "application/json"}
|
||||
|
||||
# Craft up a Requests endpoint set for this
|
||||
requests_actions = {
|
||||
"get": requests.get,
|
||||
"post": requests.post,
|
||||
"put": requests.put,
|
||||
"patch": requests.patch,
|
||||
"delete": requests.delete,
|
||||
"options": requests.options,
|
||||
}
|
||||
action = args["action"]
|
||||
|
||||
result = requests_actions[action](args["uri"], headers=headers, data=data)
|
||||
|
||||
logger.info(f"Result: {result}")
|
||||
|
||||
|
||||
hook_functions = {
|
||||
"osddb": run_hook_osddb,
|
||||
"osd": run_hook_osd,
|
||||
"pool": run_hook_pool,
|
||||
"network": run_hook_network,
|
||||
"script": run_hook_script,
|
||||
"webhook": run_hook_webhook,
|
||||
}
|
||||
|
||||
|
||||
def run_hooks(config, cspec, cluster, nodes):
|
||||
"""
|
||||
Run an Ansible bootstrap against a cluster
|
||||
"""
|
||||
# Waiting 30 seconds to ensure everything is booted an stabilized
|
||||
logger.info("Waiting 300s before starting hook run.")
|
||||
sleep(300)
|
||||
|
||||
cluster_hooks = cspec["hooks"][cluster.name]
|
||||
|
||||
cluster_nodes = db.get_nodes_in_cluster(config, cluster.name)
|
||||
|
||||
for hook in cluster_hooks:
|
||||
hook_target = hook.get("target", "all")
|
||||
hook_name = hook.get("name")
|
||||
logger.info(f"Running hook on {hook_target}: {hook_name}")
|
||||
|
||||
if "all" in hook_target:
|
||||
target_nodes = cluster_nodes
|
||||
else:
|
||||
target_nodes = [node for node in cluster_nodes if node.name in hook_target]
|
||||
|
||||
hook_type = hook.get("type")
|
||||
hook_args = hook.get("args")
|
||||
|
||||
if hook_type is None or hook_args is None:
|
||||
logger.warning("Invalid hook: missing required configuration")
|
||||
continue
|
||||
|
||||
# Run the hook function
|
||||
try:
|
||||
hook_functions[hook_type](config, target_nodes, hook_args)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running hook: {e}")
|
||||
|
||||
# Wait 5s between hooks
|
||||
sleep(5)
|
||||
|
||||
# Restart nodes to complete setup
|
||||
hook_functions['script'](config, cluster_nodes, {'script': '#!/usr/bin/env bash\necho bootstrapped | sudo tee /etc/pvc-install.hooks\nsudo reboot'})
|
86
bootstrap-daemon/pvcbootstrapd/lib/host.py
Executable file
86
bootstrap-daemon/pvcbootstrapd/lib/host.py
Executable file
@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# host.py - PVC Cluster Auto-bootstrap host libraries
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
import pvcbootstrapd.lib.db as db
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def installer_init(config, cspec, data):
|
||||
bmc_macaddr = data["bmc_macaddr"]
|
||||
bmc_ipaddr = data["bmc_ipaddr"]
|
||||
host_macaddr = data["host_macaddr"]
|
||||
host_ipaddr = data["host_ipaddr"]
|
||||
cspec_cluster = cspec["bootstrap"][bmc_macaddr]["node"]["cluster"]
|
||||
cspec_hostname = cspec["bootstrap"][bmc_macaddr]["node"]["hostname"]
|
||||
|
||||
cluster = db.get_cluster(config, name=cspec_cluster)
|
||||
if cluster is None:
|
||||
cluster = db.add_cluster(config, cspec, cspec_cluster, "provisioning")
|
||||
logger.debug(cluster)
|
||||
|
||||
db.update_node_addresses(
|
||||
config,
|
||||
cspec_cluster,
|
||||
cspec_hostname,
|
||||
bmc_macaddr,
|
||||
bmc_ipaddr,
|
||||
host_macaddr,
|
||||
host_ipaddr,
|
||||
)
|
||||
db.update_node_state(config, cspec_cluster, cspec_hostname, "installing")
|
||||
node = db.get_node(config, cspec_cluster, name=cspec_hostname)
|
||||
logger.debug(node)
|
||||
|
||||
|
||||
def installer_complete(config, cspec, data):
|
||||
bmc_macaddr = data["bmc_macaddr"]
|
||||
cspec_hostname = cspec["bootstrap"][bmc_macaddr]["node"]["hostname"]
|
||||
cspec_cluster = cspec["bootstrap"][bmc_macaddr]["node"]["cluster"]
|
||||
|
||||
db.update_node_state(config, cspec_cluster, cspec_hostname, "installed")
|
||||
node = db.get_node(config, cspec_cluster, name=cspec_hostname)
|
||||
logger.debug(node)
|
||||
|
||||
|
||||
def set_boot_state(config, cspec, data, state):
|
||||
bmc_macaddr = data["bmc_macaddr"]
|
||||
bmc_ipaddr = data["bmc_ipaddr"]
|
||||
host_macaddr = data["host_macaddr"]
|
||||
host_ipaddr = data["host_ipaddr"]
|
||||
cspec_cluster = cspec["bootstrap"][bmc_macaddr]["node"]["cluster"]
|
||||
cspec_hostname = cspec["bootstrap"][bmc_macaddr]["node"]["hostname"]
|
||||
|
||||
db.update_node_addresses(
|
||||
config,
|
||||
cspec_cluster,
|
||||
cspec_hostname,
|
||||
bmc_macaddr,
|
||||
bmc_ipaddr,
|
||||
host_macaddr,
|
||||
host_ipaddr,
|
||||
)
|
||||
db.update_node_state(config, cspec_cluster, cspec_hostname, state)
|
||||
node = db.get_node(config, cspec_cluster, name=cspec_hostname)
|
||||
logger.debug(node)
|
81
bootstrap-daemon/pvcbootstrapd/lib/installer.py
Executable file
81
bootstrap-daemon/pvcbootstrapd/lib/installer.py
Executable file
@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# lib.py - PVC Cluster Auto-bootstrap libraries
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
|
||||
#
|
||||
# Worker Functions - PXE/Installer Per-host Templates
|
||||
#
|
||||
def add_pxe(config, cspec_node, host_macaddr):
|
||||
# Generate a per-client iPXE configuration for this host
|
||||
destination_filename = (
|
||||
f"{config['tftp_host_path']}/mac-{host_macaddr.replace(':', '')}.ipxe"
|
||||
)
|
||||
template_filename = f"{config['tftp_root_path']}/host-ipxe.j2"
|
||||
|
||||
with open(template_filename, "r") as tfh:
|
||||
template = Template(tfh.read())
|
||||
|
||||
imgargs_host_list = cspec_node.get("config", {}).get("kernel_options")
|
||||
if imgargs_host_list is not None:
|
||||
imgargs_host = " ".join(imgargs_host_list)
|
||||
else:
|
||||
imgargs_host = None
|
||||
|
||||
rendered = template.render(imgargs_host=imgargs_host)
|
||||
|
||||
with open(destination_filename, "w") as dfh:
|
||||
dfh.write(rendered)
|
||||
dfh.write("\n")
|
||||
|
||||
|
||||
def add_preseed(config, cspec_node, host_macaddr, system_drive_target):
|
||||
# Generate a per-client Installer configuration for this host
|
||||
destination_filename = (
|
||||
f"{config['tftp_host_path']}/mac-{host_macaddr.replace(':', '')}.preseed"
|
||||
)
|
||||
template_filename = f"{config['tftp_root_path']}/host-preseed.j2"
|
||||
|
||||
with open(template_filename, "r") as tfh:
|
||||
template = Template(tfh.read())
|
||||
|
||||
add_packages_list = cspec_node.get("config", {}).get("packages")
|
||||
if add_packages_list is not None:
|
||||
add_packages = ",".join(add_packages_list)
|
||||
else:
|
||||
add_packages = None
|
||||
|
||||
# We use the dhcp_address here to allow the listen_address to be 0.0.0.0
|
||||
rendered = template.render(
|
||||
debrelease=cspec_node.get("config", {}).get("release"),
|
||||
debmirror=cspec_node.get("config", {}).get("mirror"),
|
||||
addpkglist=add_packages,
|
||||
filesystem=cspec_node.get("config", {}).get("filesystem"),
|
||||
skip_blockcheck=False,
|
||||
fqdn=cspec_node["node"]["fqdn"],
|
||||
target_disk=system_drive_target,
|
||||
pvcbootstrapd_checkin_uri=f"http://{config['dhcp_address']}:{config['api_port']}/checkin/host",
|
||||
)
|
||||
|
||||
with open(destination_filename, "w") as dfh:
|
||||
dfh.write(rendered)
|
||||
dfh.write("\n")
|
151
bootstrap-daemon/pvcbootstrapd/lib/lib.py
Executable file
151
bootstrap-daemon/pvcbootstrapd/lib/lib.py
Executable file
@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# lib.py - PVC Cluster Auto-bootstrap libraries
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import pvcbootstrapd.lib.db as db
|
||||
import pvcbootstrapd.lib.git as git
|
||||
import pvcbootstrapd.lib.redfish as redfish
|
||||
import pvcbootstrapd.lib.host as host
|
||||
import pvcbootstrapd.lib.ansible as ansible
|
||||
import pvcbootstrapd.lib.hooks as hooks
|
||||
|
||||
from time import sleep
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
#
|
||||
# Worker Functions - Checkins (Celery root tasks)
|
||||
#
|
||||
def dnsmasq_checkin(config, data):
|
||||
"""
|
||||
Handle checkins from DNSMasq
|
||||
"""
|
||||
logger.debug(f"data = {data}")
|
||||
|
||||
# This is an add event; what we do depends on some stuff
|
||||
if data["action"] in ["add"]:
|
||||
logger.info(
|
||||
f"Receiving 'add' checkin from DNSMasq for MAC address '{data['macaddr']}'"
|
||||
)
|
||||
cspec = git.load_cspec_yaml(config)
|
||||
is_in_bootstrap_map = True if data["macaddr"] in cspec["bootstrap"] else False
|
||||
if is_in_bootstrap_map:
|
||||
if (
|
||||
cspec["bootstrap"][data["macaddr"]]["bmc"].get("redfish", None)
|
||||
is not None
|
||||
):
|
||||
if cspec["bootstrap"][data["macaddr"]]["bmc"]["redfish"]:
|
||||
is_redfish = True
|
||||
else:
|
||||
is_redfish = False
|
||||
else:
|
||||
is_redfish = redfish.check_redfish(config, data)
|
||||
|
||||
logger.info(f"Is device '{data['macaddr']}' Redfish capable? {is_redfish}")
|
||||
if is_redfish:
|
||||
redfish.redfish_init(config, cspec, data)
|
||||
else:
|
||||
logger.warn(f"Device '{data['macaddr']}' not in bootstrap map; ignoring.")
|
||||
|
||||
return
|
||||
|
||||
# This is a tftp event; a node installer has booted
|
||||
if data["action"] in ["tftp"]:
|
||||
logger.info(
|
||||
f"Receiving 'tftp' checkin from DNSMasq for IP address '{data['destaddr']}'"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def host_checkin(config, data):
|
||||
"""
|
||||
Handle checkins from the PVC node
|
||||
"""
|
||||
logger.info(f"Registering checkin for host {data['hostname']}")
|
||||
logger.debug(f"data = {data}")
|
||||
cspec = git.load_cspec_yaml(config)
|
||||
bmc_macaddr = data["bmc_macaddr"]
|
||||
cspec_cluster = cspec["bootstrap"][bmc_macaddr]["node"]["cluster"]
|
||||
|
||||
if data["action"] in ["install-start"]:
|
||||
# Node install has started
|
||||
logger.info(f"Registering install start for host {data['hostname']}")
|
||||
host.installer_init(config, cspec, data)
|
||||
|
||||
elif data["action"] in ["install-complete"]:
|
||||
# Node install has finished
|
||||
logger.info(f"Registering install complete for host {data['hostname']}")
|
||||
host.installer_complete(config, cspec, data)
|
||||
|
||||
elif data["action"] in ["system-boot_initial"]:
|
||||
# Node has booted for the first time and can begin Ansible runs once all nodes up
|
||||
logger.info(f"Registering first boot for host {data['hostname']}")
|
||||
target_state = "booted-initial"
|
||||
|
||||
host.set_boot_state(config, cspec, data, target_state)
|
||||
sleep(1)
|
||||
|
||||
all_nodes = db.get_nodes_in_cluster(config, cspec_cluster)
|
||||
ready_nodes = [node for node in all_nodes if node.state == target_state]
|
||||
|
||||
# Continue once all nodes are in the booted-initial state
|
||||
logger.info(f"Ready: {len(ready_nodes)} All: {len(all_nodes)}")
|
||||
if len(ready_nodes) >= len(all_nodes):
|
||||
cluster = db.update_cluster_state(config, cspec_cluster, "ansible-running")
|
||||
|
||||
ansible.run_bootstrap(config, cspec, cluster, ready_nodes)
|
||||
|
||||
elif data["action"] in ["system-boot_configured"]:
|
||||
# Node has been booted after Ansible run and can begin hook runs
|
||||
logger.info(f"Registering post-Ansible boot for host {data['hostname']}")
|
||||
target_state = "booted-configured"
|
||||
|
||||
host.set_boot_state(config, cspec, data, target_state)
|
||||
sleep(1)
|
||||
|
||||
all_nodes = db.get_nodes_in_cluster(config, cspec_cluster)
|
||||
ready_nodes = [node for node in all_nodes if node.state == target_state]
|
||||
|
||||
# Continue once all nodes are in the booted-configured state
|
||||
logger.info(f"Ready: {len(ready_nodes)} All: {len(all_nodes)}")
|
||||
if len(ready_nodes) >= len(all_nodes):
|
||||
cluster = db.update_cluster_state(config, cspec_cluster, "hooks-running")
|
||||
|
||||
hooks.run_hooks(config, cspec, cluster, ready_nodes)
|
||||
|
||||
elif data["action"] in ["system-boot_completed"]:
|
||||
# Node has been fully configured and can be shut down for the final time
|
||||
logger.info(f"Registering post-hooks boot for host {data['hostname']}")
|
||||
target_state = "booted-completed"
|
||||
|
||||
host.set_boot_state(config, cspec, data, target_state)
|
||||
sleep(1)
|
||||
|
||||
all_nodes = db.get_nodes_in_cluster(config, cspec_cluster)
|
||||
ready_nodes = [node for node in all_nodes if node.state == target_state]
|
||||
|
||||
logger.info(f"Ready: {len(ready_nodes)} All: {len(all_nodes)}")
|
||||
if len(ready_nodes) >= len(all_nodes):
|
||||
cluster = db.update_cluster_state(config, cspec_cluster, "completed")
|
||||
|
||||
# Hosts will now power down ready for real activation in production
|
835
bootstrap-daemon/pvcbootstrapd/lib/redfish.py
Executable file
835
bootstrap-daemon/pvcbootstrapd/lib/redfish.py
Executable file
@ -0,0 +1,835 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# redfish.py - PVC Cluster Auto-bootstrap Redfish libraries
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
# Refs:
|
||||
# https://downloads.dell.com/manuals/all-products/esuprt_software/esuprt_it_ops_datcentr_mgmt/dell-management-solution-resources_white-papers11_en-us.pdf
|
||||
# https://downloads.dell.com/solutions/dell-management-solution-resources/RESTfulSerConfig-using-iDRAC-REST%20API%28DTC%20copy%29.pdf
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
import json
|
||||
import re
|
||||
import math
|
||||
from time import sleep
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
import pvcbootstrapd.lib.installer as installer
|
||||
import pvcbootstrapd.lib.db as db
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
#
|
||||
# Helper Classes
|
||||
#
|
||||
class AuthenticationException(Exception):
|
||||
def __init__(self, error=None, response=None):
|
||||
if error is not None:
|
||||
self.short_message = error
|
||||
else:
|
||||
self.short_message = "Generic authentication failure"
|
||||
|
||||
if response is not None:
|
||||
rinfo = response.json()["error"]["@Message.ExtendedInfo"][0]
|
||||
if rinfo.get("Message") is not None:
|
||||
self.full_message = rinfo["Message"]
|
||||
self.res_message = rinfo["Resolution"]
|
||||
self.severity = rinfo["Severity"]
|
||||
self.message_id = rinfo["MessageId"]
|
||||
else:
|
||||
self.full_message = ""
|
||||
self.res_message = ""
|
||||
self.severity = "Fatal"
|
||||
self.message_id = rinfo["MessageId"]
|
||||
self.status_code = response.status_code
|
||||
else:
|
||||
self.status_code = None
|
||||
|
||||
def __str__(self):
|
||||
if self.status_code is not None:
|
||||
message = f"{self.short_message}: {self.full_message} {self.res_message} (HTTP Code: {self.status_code}, Severity: {self.severity}, ID: {self.message_id})"
|
||||
else:
|
||||
message = f"{self.short_message}"
|
||||
return str(message)
|
||||
|
||||
|
||||
class RedfishSession:
|
||||
def __init__(self, host, username, password):
|
||||
# Disable urllib3 warnings
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# Perform login
|
||||
login_payload = {"UserName": username, "Password": password}
|
||||
login_uri = f"{host}/redfish/v1/Sessions"
|
||||
login_headers = {"content-type": "application/json"}
|
||||
|
||||
self.host = None
|
||||
login_response = None
|
||||
|
||||
tries = 1
|
||||
max_tries = 25
|
||||
while tries < max_tries:
|
||||
logger.info(f"Trying to log in to Redfish ({tries}/{max_tries - 1})...")
|
||||
try:
|
||||
login_response = requests.post(
|
||||
login_uri,
|
||||
data=json.dumps(login_payload),
|
||||
headers=login_headers,
|
||||
verify=False,
|
||||
timeout=5,
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
sleep(2)
|
||||
tries += 1
|
||||
|
||||
if login_response is None:
|
||||
logger.error("Failed to log in to Redfish")
|
||||
return
|
||||
|
||||
if login_response.status_code not in [200, 201]:
|
||||
raise AuthenticationException("Login failed", response=login_response)
|
||||
logger.info(f"Logged in to Redfish at {host} successfully")
|
||||
|
||||
self.host = host
|
||||
self.token = login_response.headers.get("X-Auth-Token")
|
||||
self.headers = {"content-type": "application/json", "x-auth-token": self.token}
|
||||
|
||||
logout_uri = login_response.headers.get("Location")
|
||||
if re.match(r"^/", logout_uri):
|
||||
self.logout_uri = f"{host}{logout_uri}"
|
||||
else:
|
||||
self.logout_uri = logout_uri
|
||||
|
||||
def __del__(self):
|
||||
if self.host is None:
|
||||
return
|
||||
|
||||
logout_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": self.token,
|
||||
}
|
||||
|
||||
logout_response = requests.delete(
|
||||
self.logout_uri, headers=logout_headers, verify=False, timeout=15
|
||||
)
|
||||
|
||||
if logout_response.status_code not in [200, 201]:
|
||||
raise AuthenticationException("Logout failed", response=logout_response)
|
||||
logger.info(f"Logged out of Redfish at {self.host} successfully")
|
||||
|
||||
def get(self, uri):
|
||||
url = f"{self.host}{uri}"
|
||||
|
||||
response = requests.get(url, headers=self.headers, verify=False)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
return response.json()
|
||||
else:
|
||||
rinfo = response.json()["error"]["@Message.ExtendedInfo"][0]
|
||||
if rinfo.get("Message") is not None:
|
||||
message = f"{rinfo['Message']} {rinfo['Resolution']}"
|
||||
severity = rinfo["Severity"]
|
||||
message_id = rinfo["MessageId"]
|
||||
else:
|
||||
message = rinfo
|
||||
severity = "Error"
|
||||
message_id = "N/A"
|
||||
logger.warn(f"! Error: GET request to {url} failed")
|
||||
logger.warn(
|
||||
f"! HTTP Code: {response.status_code} Severity: {severity} ID: {message_id}"
|
||||
)
|
||||
logger.warn(f"! Details: {message}")
|
||||
return None
|
||||
|
||||
def delete(self, uri):
|
||||
url = f"{self.host}{uri}"
|
||||
|
||||
response = requests.delete(url, headers=self.headers, verify=False)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
return response.json()
|
||||
else:
|
||||
rinfo = response.json()["error"]["@Message.ExtendedInfo"][0]
|
||||
if rinfo.get("Message") is not None:
|
||||
message = f"{rinfo['Message']} {rinfo['Resolution']}"
|
||||
severity = rinfo["Severity"]
|
||||
message_id = rinfo["MessageId"]
|
||||
else:
|
||||
message = rinfo
|
||||
severity = "Error"
|
||||
message_id = "N/A"
|
||||
|
||||
logger.warn(f"! Error: DELETE request to {url} failed")
|
||||
logger.warn(
|
||||
f"! HTTP Code: {response.status_code} Severity: {severity} ID: {message_id}"
|
||||
)
|
||||
logger.warn(f"! Details: {message}")
|
||||
return None
|
||||
|
||||
def post(self, uri, data):
|
||||
url = f"{self.host}{uri}"
|
||||
payload = json.dumps(data)
|
||||
|
||||
response = requests.post(url, data=payload, headers=self.headers, verify=False)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
return response.json()
|
||||
else:
|
||||
rinfo = response.json()["error"]["@Message.ExtendedInfo"][0]
|
||||
if rinfo.get("Message") is not None:
|
||||
message = f"{rinfo['Message']} {rinfo['Resolution']}"
|
||||
severity = rinfo["Severity"]
|
||||
message_id = rinfo["MessageId"]
|
||||
else:
|
||||
message = rinfo
|
||||
severity = "Error"
|
||||
message_id = "N/A"
|
||||
|
||||
logger.warn(f"! Error: POST request to {url} failed")
|
||||
logger.warn(
|
||||
f"! HTTP Code: {response.status_code} Severity: {severity} ID: {message_id}"
|
||||
)
|
||||
logger.warn(f"! Details: {message}")
|
||||
return None
|
||||
|
||||
def put(self, uri, data):
|
||||
url = f"{self.host}{uri}"
|
||||
payload = json.dumps(data)
|
||||
|
||||
response = requests.put(url, data=payload, headers=self.headers, verify=False)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
return response.json()
|
||||
else:
|
||||
rinfo = response.json()["error"]["@Message.ExtendedInfo"][0]
|
||||
if rinfo.get("Message") is not None:
|
||||
message = f"{rinfo['Message']} {rinfo['Resolution']}"
|
||||
severity = rinfo["Severity"]
|
||||
message_id = rinfo["MessageId"]
|
||||
else:
|
||||
message = rinfo
|
||||
severity = "Error"
|
||||
message_id = "N/A"
|
||||
|
||||
logger.warn(f"! Error: PUT request to {url} failed")
|
||||
logger.warn(
|
||||
f"! HTTP Code: {response.status_code} Severity: {severity} ID: {message_id}"
|
||||
)
|
||||
logger.warn(f"! Details: {message}")
|
||||
return None
|
||||
|
||||
def patch(self, uri, data):
|
||||
url = f"{self.host}{uri}"
|
||||
payload = json.dumps(data)
|
||||
|
||||
response = requests.patch(url, data=payload, headers=self.headers, verify=False)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
return response.json()
|
||||
else:
|
||||
rinfo = response.json()["error"]["@Message.ExtendedInfo"][0]
|
||||
if rinfo.get("Message") is not None:
|
||||
message = f"{rinfo['Message']} {rinfo['Resolution']}"
|
||||
severity = rinfo["Severity"]
|
||||
message_id = rinfo["MessageId"]
|
||||
else:
|
||||
message = rinfo
|
||||
severity = "Error"
|
||||
message_id = "N/A"
|
||||
|
||||
logger.warn(f"! Error: PATCH request to {url} failed")
|
||||
logger.warn(
|
||||
f"! HTTP Code: {response.status_code} Severity: {severity} ID: {message_id}"
|
||||
)
|
||||
logger.warn(f"! Details: {message}")
|
||||
return None
|
||||
|
||||
|
||||
#
|
||||
# Helper functions
|
||||
#
|
||||
def format_bytes_tohuman(databytes):
|
||||
"""
|
||||
Format a string of bytes into a human-readable value (using base-1000)
|
||||
"""
|
||||
# Matrix of human-to-byte values
|
||||
byte_unit_matrix = {
|
||||
"B": 1,
|
||||
"KB": 1000,
|
||||
"MB": 1000 * 1000,
|
||||
"GB": 1000 * 1000 * 1000,
|
||||
"TB": 1000 * 1000 * 1000 * 1000,
|
||||
"PB": 1000 * 1000 * 1000 * 1000 * 1000,
|
||||
"EB": 1000 * 1000 * 1000 * 1000 * 1000 * 1000,
|
||||
}
|
||||
|
||||
datahuman = ""
|
||||
for unit in sorted(byte_unit_matrix, key=byte_unit_matrix.get, reverse=True):
|
||||
if unit in ["TB", "PB", "EB"]:
|
||||
# Handle the situation where we might want to round to integer values
|
||||
# for some entries (2TB) but not others (e.g. 1.92TB). We round if the
|
||||
# result is within +/- 2% of the integer value, otherwise we use two
|
||||
# decimal places.
|
||||
new_bytes = databytes / byte_unit_matrix[unit]
|
||||
new_bytes_plustwopct = new_bytes * 1.02
|
||||
new_bytes_minustwopct = new_bytes * 0.98
|
||||
cieled_bytes = int(math.ceil(databytes / byte_unit_matrix[unit]))
|
||||
rounded_bytes = round(databytes / byte_unit_matrix[unit], 2)
|
||||
if (
|
||||
cieled_bytes > new_bytes_minustwopct
|
||||
and cieled_bytes < new_bytes_plustwopct
|
||||
):
|
||||
new_bytes = cieled_bytes
|
||||
else:
|
||||
new_bytes = rounded_bytes
|
||||
|
||||
# Round up if 5 or more digits
|
||||
if new_bytes > 999:
|
||||
# We can jump down another level
|
||||
continue
|
||||
else:
|
||||
# We're at the end, display with this size
|
||||
datahuman = "{}{}".format(new_bytes, unit)
|
||||
|
||||
return datahuman
|
||||
|
||||
|
||||
def get_system_drive_target(session, cspec_node, storage_root):
|
||||
"""
|
||||
Determine the system drive target for the installer
|
||||
"""
|
||||
# Handle an invalid >2 number of system disks, use only first 2
|
||||
if len(cspec_node["config"]["system_disks"]) > 2:
|
||||
cspec_drives = cspec_node["config"]["system_disks"][0:2]
|
||||
else:
|
||||
cspec_drives = cspec_node["config"]["system_disks"]
|
||||
|
||||
# If we have no storage root, we just return the first entry from
|
||||
# the cpsec_drives as-is and hope the administrator has the right
|
||||
# format here.
|
||||
if storage_root is None:
|
||||
return cspec_drives[0]
|
||||
# We proceed with Redfish configuration to determine the disks
|
||||
else:
|
||||
storage_detail = session.get(storage_root)
|
||||
|
||||
# Grab a full list of drives
|
||||
drive_list = list()
|
||||
for storage_member in storage_detail["Members"]:
|
||||
storage_member_root = storage_member["@odata.id"]
|
||||
storage_member_detail = session.get(storage_member_root)
|
||||
for drive in storage_member_detail["Drives"]:
|
||||
drive_root = drive["@odata.id"]
|
||||
drive_detail = session.get(drive_root)
|
||||
drive_list.append(drive_detail)
|
||||
|
||||
system_drives = list()
|
||||
|
||||
# Iterate through each drive and include those that match
|
||||
for cspec_drive in cspec_drives:
|
||||
if re.match(r"^\/dev", cspec_drive) or re.match(r"^detect:", cspec_drive):
|
||||
# We only match the first drive that has these conditions for use in the preseed config
|
||||
logger.info(
|
||||
"Found a drive with a 'detect:' string or Linux '/dev' path, using it for bootstrap."
|
||||
)
|
||||
return cspec_drive
|
||||
|
||||
# Match any chassis-ID spec drives
|
||||
for drive in drive_list:
|
||||
# Like "Disk.Bay.2:Enclosure.Internal.0-1:RAID.Integrated.1-1"
|
||||
drive_name = drive["Id"].split(":")[0]
|
||||
# Craft up the cspec version of this
|
||||
cspec_drive_name = f"Drive.Bay.{cspec_drive}"
|
||||
if drive_name == cspec_drive_name:
|
||||
system_drives.append(drive)
|
||||
|
||||
# We found a single drive, so determine its actual detect string
|
||||
if len(system_drives) == 1:
|
||||
logger.info(
|
||||
"Found a single drive matching the requested chassis ID, using it as the system disk."
|
||||
)
|
||||
|
||||
# Get the model's first word
|
||||
drive_model = system_drives[0].get("Model", "INVALID").split()[0]
|
||||
# Get and convert the size in bytes value to human
|
||||
drive_size_bytes = system_drives[0].get("CapacityBytes", 0)
|
||||
drive_size_human = format_bytes_tohuman(drive_size_bytes)
|
||||
# Get the drive ID out of all the valid entries
|
||||
# How this works is that, for each non-array disk, we must find what position our exact disk is
|
||||
# So for example, say we want disk 3 out of 4, and all 4 are the same size and model and not in
|
||||
# another (RAID) volume. This will give us an index of 2. Then in the installer this will match
|
||||
# the 3rd list entry from "lsscsi". This is probably an unneccessary hack, since people will
|
||||
# probably just give the first disk if they want one, or 2 disks if they want a RAID-1, but this
|
||||
# is here just in case
|
||||
idx = 0
|
||||
for drive in drive_list:
|
||||
list_drive_model = drive.get("Model", "INVALID").split()[0]
|
||||
list_drive_size_bytes = drive.get("CapacityBytes", 0)
|
||||
list_drive_in_array = (
|
||||
False
|
||||
if drive.get("Links", {})
|
||||
.get("Volumes", [""])[0]
|
||||
.get("@odata.id")
|
||||
.split("/")[-1]
|
||||
== drive.get("Id")
|
||||
else True
|
||||
)
|
||||
if (
|
||||
drive_model == list_drive_model
|
||||
and drive_size_bytes == list_drive_size_bytes
|
||||
and not list_drive_in_array
|
||||
):
|
||||
index = idx
|
||||
idx += 1
|
||||
drive_id = index
|
||||
|
||||
# Create the target string
|
||||
system_drive_target = f"detect:{drive_model}:{drive_size_human}:{drive_id}"
|
||||
|
||||
# We found two drives, so create a RAID-1 array then determine the volume's detect string
|
||||
elif len(system_drives) == 2:
|
||||
logger.info(
|
||||
"Found two drives matching the requested chassis IDs, creating a RAID-1 and using it as the system disk."
|
||||
)
|
||||
|
||||
drive_one = system_drives[0]
|
||||
drive_one_id = drive_one.get("Id", "INVALID")
|
||||
drive_one_path = drive_one.get("@odata.id", "INVALID")
|
||||
drive_one_controller = drive_one_id.split(":")[-1]
|
||||
drive_two = system_drives[1]
|
||||
drive_two_id = drive_two.get("Id", "INVALID")
|
||||
drive_two_path = drive_two.get("@odata.id", "INVALID")
|
||||
drive_two_controller = drive_two_id.split(":")[-1]
|
||||
|
||||
# Determine that the drives are on the same controller
|
||||
if drive_one_controller != drive_two_controller:
|
||||
logger.error(
|
||||
"Two drives are not on the same controller; this should not happen"
|
||||
)
|
||||
return None
|
||||
|
||||
# Get the controller details
|
||||
controller_root = f"{storage_root}/{drive_one_controller}"
|
||||
controller_detail = session.get(controller_root)
|
||||
|
||||
# Get the name of the controller (for crafting the detect string)
|
||||
controller_name = controller_detail.get("Name", "INVALID").split()[0]
|
||||
|
||||
# Get the volume root for the controller
|
||||
controller_volume_root = controller_detail.get("Volumes", {}).get(
|
||||
"@odata.id"
|
||||
)
|
||||
|
||||
# Get the pre-creation list of volumes on the controller
|
||||
controller_volumes_pre = [
|
||||
volume["@odata.id"]
|
||||
for volume in session.get(controller_volume_root).get("Members", [])
|
||||
]
|
||||
|
||||
# Create the RAID-1 volume
|
||||
payload = {
|
||||
"VolumeType": "Mirrored",
|
||||
"Drives": [
|
||||
{"@odata.id": drive_one_path},
|
||||
{"@odata.id": drive_two_path},
|
||||
],
|
||||
}
|
||||
session.post(controller_volume_root, payload)
|
||||
|
||||
# Wait for the volume to be created
|
||||
new_volume_list = []
|
||||
while len(new_volume_list) < 1:
|
||||
sleep(5)
|
||||
controller_volumes_post = [
|
||||
volume["@odata.id"]
|
||||
for volume in session.get(controller_volume_root).get("Members", [])
|
||||
]
|
||||
new_volume_list = list(
|
||||
set(controller_volumes_post).difference(controller_volumes_pre)
|
||||
)
|
||||
new_volume_root = new_volume_list[0]
|
||||
|
||||
# Get the IDX of the volume out of any others
|
||||
volume_id = 0
|
||||
for idx, volume in enumerate(controller_volumes_post):
|
||||
if volume == new_volume_root:
|
||||
volume_id = idx
|
||||
break
|
||||
|
||||
# Get and convert the size in bytes value to human
|
||||
volume_detail = session.get(new_volume_root)
|
||||
volume_size_bytes = volume_detail.get("CapacityBytes", 0)
|
||||
volume_size_human = format_bytes_tohuman(volume_size_bytes)
|
||||
|
||||
# Create the target string
|
||||
system_drive_target = (
|
||||
f"detect:{controller_name}:{volume_size_human}:{volume_id}"
|
||||
)
|
||||
|
||||
# We found too few or too many drives, error
|
||||
else:
|
||||
system_drive_target = None
|
||||
|
||||
return system_drive_target
|
||||
|
||||
|
||||
#
|
||||
# Redfish Task functions
|
||||
#
|
||||
def set_indicator_state(session, system_root, redfish_vendor, state):
|
||||
"""
|
||||
Set the system indicator LED to the desired state (on/off)
|
||||
"""
|
||||
state_values_write = {
|
||||
"Dell": {
|
||||
"on": "Blinking",
|
||||
"off": "Off",
|
||||
},
|
||||
"default": {
|
||||
"on": "Lit",
|
||||
"off": "Off",
|
||||
},
|
||||
}
|
||||
|
||||
state_values_read = {
|
||||
"default": {
|
||||
"on": "Lit",
|
||||
"off": "Off",
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
# Allow vendor-specific overrides
|
||||
if redfish_vendor not in state_values_write:
|
||||
redfish_vendor = "default"
|
||||
# Allow nice names ("on"/"off")
|
||||
if state in state_values_write[redfish_vendor]:
|
||||
state = state_values_write[redfish_vendor][state]
|
||||
|
||||
# Get current state
|
||||
system_detail = session.get(system_root)
|
||||
current_state = system_detail["IndicatorLED"]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
try:
|
||||
state_read = state
|
||||
# Allow vendor-specific overrides
|
||||
if redfish_vendor not in state_values_read:
|
||||
redfish_vendor = "default"
|
||||
# Allow nice names ("on"/"off")
|
||||
if state_read in state_values_read[redfish_vendor]:
|
||||
state_read = state_values_read[redfish_vendor][state]
|
||||
|
||||
if state_read == current_state:
|
||||
return False
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
session.patch(system_root, {"IndicatorLED": state})
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def set_power_state(session, system_root, redfish_vendor, state):
|
||||
"""
|
||||
Set the system power state to the desired state
|
||||
"""
|
||||
state_values = {
|
||||
"default": {
|
||||
"on": "On",
|
||||
"off": "ForceOff",
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
# Allow vendor-specific overrides
|
||||
if redfish_vendor not in state_values:
|
||||
redfish_vendor = "default"
|
||||
# Allow nice names ("on"/"off")
|
||||
if state in state_values[redfish_vendor]:
|
||||
state = state_values[redfish_vendor][state]
|
||||
|
||||
# Get current state, target URI, and allowable values
|
||||
system_detail = session.get(system_root)
|
||||
current_state = system_detail["PowerState"]
|
||||
power_root = system_detail["Actions"]["#ComputerSystem.Reset"]["target"]
|
||||
power_choices = system_detail["Actions"]["#ComputerSystem.Reset"][
|
||||
"ResetType@Redfish.AllowableValues"
|
||||
]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
# Remap some namings so we can check the current state against the target state
|
||||
if state in ["ForceOff"]:
|
||||
target_state = "Off"
|
||||
else:
|
||||
target_state = state
|
||||
|
||||
if target_state == current_state:
|
||||
return False
|
||||
|
||||
if state not in power_choices:
|
||||
return False
|
||||
|
||||
session.post(power_root, {"ResetType": state})
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def set_boot_override(session, system_root, redfish_vendor, target):
|
||||
"""
|
||||
Set the system boot override to the desired target
|
||||
"""
|
||||
try:
|
||||
system_detail = session.get(system_root)
|
||||
boot_targets = system_detail["Boot"]["BootSourceOverrideSupported"]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
if target not in boot_targets:
|
||||
return False
|
||||
|
||||
session.patch(system_root, {"Boot": {"BootSourceOverrideTarget": target}})
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_redfish(config, data):
|
||||
"""
|
||||
Validate that a BMC is Redfish-capable
|
||||
"""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
logger.info("Checking for Redfish response...")
|
||||
count = 0
|
||||
while True:
|
||||
try:
|
||||
count += 1
|
||||
if count > 30:
|
||||
retcode = 500
|
||||
logger.warn("Aborted after 300s; device too slow or not booting.")
|
||||
break
|
||||
resp = requests.get(
|
||||
f"https://{data['ipaddr']}/redfish/v1",
|
||||
headers=headers,
|
||||
verify=False,
|
||||
timeout=10,
|
||||
)
|
||||
retcode = resp.retcode
|
||||
break
|
||||
except Exception:
|
||||
logger.info(f"Attempt {count}...")
|
||||
continue
|
||||
|
||||
if retcode == 200:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
#
|
||||
# Entry function
|
||||
#
|
||||
def redfish_init(config, cspec, data):
|
||||
"""
|
||||
Initialize a new node with Redfish
|
||||
"""
|
||||
bmc_ipaddr = data["ipaddr"]
|
||||
bmc_macaddr = data["macaddr"]
|
||||
bmc_host = f"https://{bmc_ipaddr}"
|
||||
|
||||
cspec_node = cspec["bootstrap"][bmc_macaddr]
|
||||
logger.debug(f"cspec_node = {cspec_node}")
|
||||
|
||||
bmc_username = cspec_node["bmc"]["username"]
|
||||
bmc_password = cspec_node["bmc"]["password"]
|
||||
|
||||
host_macaddr = ""
|
||||
host_ipaddr = ""
|
||||
|
||||
cspec_cluster = cspec_node["node"]["cluster"]
|
||||
cspec_hostname = cspec_node["node"]["hostname"]
|
||||
|
||||
cluster = db.get_cluster(config, name=cspec_cluster)
|
||||
if cluster is None:
|
||||
cluster = db.add_cluster(config, cspec, cspec_cluster, "provisioning")
|
||||
|
||||
logger.debug(cluster)
|
||||
|
||||
db.update_node_state(config, cspec_cluster, cspec_hostname, "characterzing")
|
||||
db.update_node_addresses(
|
||||
config,
|
||||
cspec_cluster,
|
||||
cspec_hostname,
|
||||
bmc_macaddr,
|
||||
bmc_ipaddr,
|
||||
host_macaddr,
|
||||
host_ipaddr,
|
||||
)
|
||||
node = db.get_node(config, cspec_cluster, name=cspec_hostname)
|
||||
logger.debug(node)
|
||||
|
||||
# Create the session and log in
|
||||
session = RedfishSession(bmc_host, bmc_username, bmc_password)
|
||||
if session.host is None:
|
||||
logger.info("Aborting Redfish configuration; reboot BMC to try again.")
|
||||
del session
|
||||
return
|
||||
|
||||
logger.info("Characterizing node...")
|
||||
# Get Refish bases
|
||||
redfish_base_root = "/redfish/v1"
|
||||
redfish_base_detail = session.get(redfish_base_root)
|
||||
|
||||
redfish_vendor = list(redfish_base_detail["Oem"].keys())[0]
|
||||
redfish_name = redfish_base_detail["Name"]
|
||||
redfish_version = redfish_base_detail["RedfishVersion"]
|
||||
|
||||
systems_base_root = redfish_base_detail["Systems"]["@odata.id"].rstrip("/")
|
||||
systems_base_detail = session.get(systems_base_root)
|
||||
|
||||
system_root = systems_base_detail["Members"][0]["@odata.id"].rstrip("/")
|
||||
|
||||
# Force off the system and turn on the indicator
|
||||
set_power_state(session, system_root, redfish_vendor, "off")
|
||||
set_indicator_state(session, system_root, redfish_vendor, "on")
|
||||
|
||||
# Get the system details
|
||||
system_detail = session.get(system_root)
|
||||
|
||||
system_sku = system_detail["SKU"].strip()
|
||||
system_serial = system_detail["SerialNumber"].strip()
|
||||
system_power_state = system_detail["PowerState"].strip()
|
||||
system_indicator_state = system_detail["IndicatorLED"].strip()
|
||||
system_health_state = system_detail["Status"]["Health"].strip()
|
||||
|
||||
# Walk down the EthernetInterfaces construct to get the bootstrap interface MAC address
|
||||
try:
|
||||
ethernet_root = system_detail["EthernetInterfaces"]["@odata.id"].rstrip("/")
|
||||
ethernet_detail = session.get(ethernet_root)
|
||||
first_interface_root = ethernet_detail["Members"][0]["@odata.id"].rstrip("/")
|
||||
first_interface_detail = session.get(first_interface_root)
|
||||
# Something went wrong, so fall back
|
||||
except KeyError:
|
||||
first_interface_detail = dict()
|
||||
|
||||
# Try to get the MAC address directly from the interface detail (Redfish standard)
|
||||
if first_interface_detail.get("MACAddress") is not None:
|
||||
bootstrap_mac_address = first_interface_detail["MACAddress"].strip().lower()
|
||||
# Try to get the MAC address from the HostCorrelation->HostMACAddress (HP DL360x G8)
|
||||
elif len(system_detail.get("HostCorrelation", {}).get("HostMACAddress", [])) > 0:
|
||||
bootstrap_mac_address = (
|
||||
system_detail["HostCorrelation"]["HostMACAddress"][0].strip().lower()
|
||||
)
|
||||
# We can't find it, so use a dummy value
|
||||
else:
|
||||
logger.error("Could not find a valid MAC address for the bootstrap interface.")
|
||||
return
|
||||
|
||||
# Display the system details
|
||||
logger.info("Found details from node characterization:")
|
||||
logger.info(f"> System Manufacturer: {redfish_vendor}")
|
||||
logger.info(f"> System Redfish Version: {redfish_version}")
|
||||
logger.info(f"> System Redfish Name: {redfish_name}")
|
||||
logger.info(f"> System SKU: {system_sku}")
|
||||
logger.info(f"> System Serial: {system_serial}")
|
||||
logger.info(f"> Power State: {system_power_state}")
|
||||
logger.info(f"> Indicator LED: {system_indicator_state}")
|
||||
logger.info(f"> Health State: {system_health_state}")
|
||||
logger.info(f"> Bootstrap NIC MAC: {bootstrap_mac_address}")
|
||||
|
||||
# Update node host MAC address
|
||||
host_macaddr = bootstrap_mac_address
|
||||
node = db.update_node_addresses(
|
||||
config,
|
||||
cspec_cluster,
|
||||
cspec_hostname,
|
||||
bmc_macaddr,
|
||||
bmc_ipaddr,
|
||||
host_macaddr,
|
||||
host_ipaddr,
|
||||
)
|
||||
logger.debug(node)
|
||||
|
||||
logger.info("Determining system disk...")
|
||||
storage_root = system_detail.get("Storage", {}).get("@odata.id")
|
||||
system_drive_target = get_system_drive_target(session, cspec_node, storage_root)
|
||||
if system_drive_target is None:
|
||||
logger.error(
|
||||
"No valid drives found; configure a single system drive as a 'detect:' string or Linux '/dev' path instead and try again."
|
||||
)
|
||||
return
|
||||
logger.info(f"Found system disk {system_drive_target}")
|
||||
|
||||
# Create our preseed configuration
|
||||
logger.info("Creating node boot configurations...")
|
||||
installer.add_pxe(config, cspec_node, host_macaddr)
|
||||
installer.add_preseed(config, cspec_node, host_macaddr, system_drive_target)
|
||||
|
||||
# Adjust any BIOS settings
|
||||
logger.info("Adjusting BIOS settings...")
|
||||
bios_root = system_detail.get("Bios", {}).get("@odata.id")
|
||||
if bios_root is not None:
|
||||
bios_detail = session.get(bios_root)
|
||||
bios_attributes = list(bios_detail["Attributes"].keys())
|
||||
for setting, value in cspec_node["bmc"].get("bios_settings", {}).items():
|
||||
if setting not in bios_attributes:
|
||||
continue
|
||||
|
||||
payload = {"Attributes": {setting: value}}
|
||||
session.patch(f"{bios_root}/Settings", payload)
|
||||
|
||||
# Set boot override to Pxe for the installer boot
|
||||
logger.info("Setting temporary PXE boot...")
|
||||
set_boot_override(session, system_root, redfish_vendor, "Pxe")
|
||||
|
||||
# Turn on the system
|
||||
logger.info("Powering on node...")
|
||||
set_power_state(session, system_root, redfish_vendor, "on")
|
||||
|
||||
node = db.update_node_state(config, cspec_cluster, cspec_hostname, "pxe-booting")
|
||||
|
||||
logger.info("Waiting for completion of node and cluster installation...")
|
||||
# Wait for the system to install and be configured
|
||||
while node.state != "booted-completed":
|
||||
sleep(60)
|
||||
# Keep the Redfish session alive
|
||||
session.get(redfish_base_root)
|
||||
# Refresh our node state
|
||||
node = db.get_node(config, cspec_cluster, name=cspec_hostname)
|
||||
|
||||
# Graceful shutdown of the machine
|
||||
set_power_state(session, system_root, redfish_vendor, "GracefulShutdown")
|
||||
system_power_state = "On"
|
||||
while system_power_state != "Off":
|
||||
sleep(5)
|
||||
# Refresh our power state from the system details
|
||||
system_detail = session.get(system_root)
|
||||
system_power_state = system_detail["PowerState"].strip()
|
||||
|
||||
# Turn off the indicator to indicate bootstrap has completed
|
||||
set_indicator_state(session, system_root, redfish_vendor, "off")
|
||||
|
||||
# We must delete the session
|
||||
del session
|
||||
return
|
45
bootstrap-daemon/pvcbootstrapd/lib/tftp.py
Executable file
45
bootstrap-daemon/pvcbootstrapd/lib/tftp.py
Executable file
@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# tftp.py - PVC Cluster Auto-bootstrap TFTP preparation libraries
|
||||
# Part of the Parallel Virtual Cluster (PVC) system
|
||||
#
|
||||
# Copyright (C) 2018-2021 Joshua M. Boniface <joshua@boniface.me>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
import os.path
|
||||
import shutil
|
||||
|
||||
|
||||
def build_tftp_repository(config):
|
||||
# Generate an installer config
|
||||
build_cmd = f"{config['ansible_path']}/pvc-installer/buildpxe.sh -o {config['tftp_root_path']} -u {config['deploy_username']}"
|
||||
print(f"Building TFTP contents via pvc-installer command: {build_cmd}")
|
||||
os.system(build_cmd)
|
||||
|
||||
|
||||
def init_tftp(config):
|
||||
"""
|
||||
Prepare a TFTP root
|
||||
"""
|
||||
if not os.path.exists(config["tftp_root_path"]):
|
||||
print("First run: building TFTP root and contents - this will take some time!")
|
||||
os.makedirs(config["tftp_root_path"])
|
||||
os.makedirs(config["tftp_host_path"])
|
||||
shutil.copyfile(
|
||||
f"{config['ansible_keyfile']}.pub", f"{config['tftp_root_path']}/keys.txt"
|
||||
)
|
||||
|
||||
build_tftp_repository(config)
|
11
bootstrap-daemon/requirements.txt
Normal file
11
bootstrap-daemon/requirements.txt
Normal file
@ -0,0 +1,11 @@
|
||||
ansible
|
||||
ansible_runner
|
||||
celery
|
||||
flask
|
||||
flask_restful
|
||||
gevent
|
||||
gitpython
|
||||
paramiko
|
||||
pyyaml
|
||||
redis
|
||||
requests
|
Reference in New Issue
Block a user