From 4e42ef50fa51c24f6a4e18f7b653b8da63e16584 Mon Sep 17 00:00:00 2001 From: Mohammed Boukhalfa Date: Thu, 11 Jul 2024 14:51:33 +0300 Subject: [PATCH] Add fake-ipa Signed-off-by: Mohammed Boukhalfa --- .gitignore | 19 + fake-ipa/Dockerfile | 9 + fake-ipa/README.md | 48 +++ fake-ipa/Run-env/01-vm-setup.sh | 99 +++++ fake-ipa/Run-env/02-configure-minikube.sh | 14 + .../03-images-and-run-local-services.sh | 72 ++++ fake-ipa/Run-env/04-start-minikube.sh | 16 + fake-ipa/Run-env/05-apply-manifests.sh | 78 ++++ fake-ipa/Run-env/Init-environment.sh | 6 + .../build-sushy-tools-and-fake-ipa-images.sh | 32 ++ fake-ipa/Run-env/clean.sh | 36 ++ fake-ipa/conf.py | 37 ++ fake-ipa/fake_ipa/__init__.py | 0 fake-ipa/fake_ipa/base.py | 346 ++++++++++++++++++ fake-ipa/fake_ipa/clean.py | 89 +++++ fake-ipa/fake_ipa/deploy.py | 85 +++++ fake-ipa/fake_ipa/encoding.py | 79 ++++ fake-ipa/fake_ipa/error.py | 225 ++++++++++++ fake-ipa/fake_ipa/fake_agent.py | 180 +++++++++ fake-ipa/fake_ipa/heartbeater.py | 167 +++++++++ fake-ipa/fake_ipa/image.py | 42 +++ fake-ipa/fake_ipa/inspector.py | 64 ++++ fake-ipa/fake_ipa/ironic_api_client.py | 268 ++++++++++++++ fake-ipa/fake_ipa/log.py | 52 +++ fake-ipa/fake_ipa/main.py | 300 +++++++++++++++ fake-ipa/fake_ipa/standby.py | 52 +++ fake-ipa/requirements.txt | 3 + fake-ipa/setup.cfg | 12 + fake-ipa/setup.py | 20 + 29 files changed, 2450 insertions(+) create mode 100644 .gitignore create mode 100644 fake-ipa/Dockerfile create mode 100644 fake-ipa/README.md create mode 100755 fake-ipa/Run-env/01-vm-setup.sh create mode 100755 fake-ipa/Run-env/02-configure-minikube.sh create mode 100755 fake-ipa/Run-env/03-images-and-run-local-services.sh create mode 100755 fake-ipa/Run-env/04-start-minikube.sh create mode 100755 fake-ipa/Run-env/05-apply-manifests.sh create mode 100755 fake-ipa/Run-env/Init-environment.sh create mode 100755 fake-ipa/Run-env/build-sushy-tools-and-fake-ipa-images.sh create mode 100755 fake-ipa/Run-env/clean.sh create mode 100644 fake-ipa/conf.py create mode 100644 fake-ipa/fake_ipa/__init__.py create mode 100644 fake-ipa/fake_ipa/base.py create mode 100644 fake-ipa/fake_ipa/clean.py create mode 100644 fake-ipa/fake_ipa/deploy.py create mode 100644 fake-ipa/fake_ipa/encoding.py create mode 100644 fake-ipa/fake_ipa/error.py create mode 100644 fake-ipa/fake_ipa/fake_agent.py create mode 100644 fake-ipa/fake_ipa/heartbeater.py create mode 100644 fake-ipa/fake_ipa/image.py create mode 100644 fake-ipa/fake_ipa/inspector.py create mode 100644 fake-ipa/fake_ipa/ironic_api_client.py create mode 100644 fake-ipa/fake_ipa/log.py create mode 100644 fake-ipa/fake_ipa/main.py create mode 100644 fake-ipa/fake_ipa/standby.py create mode 100644 fake-ipa/requirements.txt create mode 100644 fake-ipa/setup.cfg create mode 100644 fake-ipa/setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a0cb47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +__pycache__/ +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST \ No newline at end of file diff --git a/fake-ipa/Dockerfile b/fake-ipa/Dockerfile new file mode 100644 index 0000000..828b2f6 --- /dev/null +++ b/fake-ipa/Dockerfile @@ -0,0 +1,9 @@ +ARG BASE_IMAGE=python:3.9-slim +FROM $BASE_IMAGE +ENV PBR_VERSION=6.0.0 +COPY . /root/fake-ipa/ +WORKDIR /root/fake-ipa/ +ENV CONFIG ${CONFIG:-/root/fake-ipa/conf.py} +RUN python3 -m pip install -r requirements.txt +RUN python3 setup.py install +CMD fake-ipa --config $CONFIG \ No newline at end of file diff --git a/fake-ipa/README.md b/fake-ipa/README.md new file mode 100644 index 0000000..9eb75c7 --- /dev/null +++ b/fake-ipa/README.md @@ -0,0 +1,48 @@ +# Fake ironic python agent + +Fake ipa is a tool to help test ironic communication with IPA. + +Fake ipa simulate the IPA by: + +- Running an API server with the needed real IPA endpoint. +- Send back fake inspection data when requested. +- lookup the node and save tokens. +- Heartbeating to ironic API with several threads looping over + a queue of fake agents. +- Faking the sync/async commands needed by ironic to inspect, + clean and provision a node. + +## Run Fake ipa with ironic + +### Requirements + +Machine: `4c / 16gb / 100gb` +OS: `CentOS9-20220330` + +### Test fake ipa + +1. clone the env scripts `cd` inside `fake-ipa/Run-env` folder +2. check configs in `config.py` +3. run init `./Init-environment.sh` +4. to just rebuild fake-ipa from the local repo run `./rebuild-fipa.sh` + +### Use ironic with fake-ipa + +At this step there will be an ironic environment using fake-ipa, +by default there will be two nodes created on ironic you can list them with: +`baremetal node list` + +To manage the a node using the ironicclient: +`baremetal node manage ` + +To inspect use: `baremetal node inspect ` +then you can provide and deploy image on the node with: + +```bash +baremetal node provide +baremetal node deploy +``` + +### Clean + +To clean the env `./clean.sh` diff --git a/fake-ipa/Run-env/01-vm-setup.sh b/fake-ipa/Run-env/01-vm-setup.sh new file mode 100755 index 0000000..a078d47 --- /dev/null +++ b/fake-ipa/Run-env/01-vm-setup.sh @@ -0,0 +1,99 @@ +set -e +#install kvm for minikube +dnf -y install qemu-kvm libvirt virt-install net-tools podman firewalld +systemctl enable --now libvirtd +systemctl start firewalld +systemctl enable firewalld +# create provisioning network +cat <provisioning.xml + + + + + + provisioning + + + +EOF + +cat <baremetal.xml + + baremetal + + + + + + + + + + + + + + + + + + + + + + + + + +EOF +# define networks +virsh net-define baremetal.xml +virsh net-start baremetal +virsh net-autostart baremetal + +virsh net-define provisioning.xml +virsh net-start provisioning +virsh net-autostart provisioning +tee -a /etc/NetworkManager/system-connections/provisioning.nmconnection <"$SCRIPT_DIR/tmp/sushy-tools/htpasswd" +admin:$2b$12$/dVOBNatORwKpF.ss99KB.vESjfyONOxyH.UgRwNyZi1Xs/W2pGVS +EOF +# Create directories +DIRECTORIES=( + "/opt/metal3-dev-env/ironic/virtualbmc" + "/opt/metal3-dev-env/ironic/virtualbmc/sushy-tools" + "/opt/metal3-dev-env/ironic/html/images" + "tmp/cert" +) +for DIR in "${DIRECTORIES[@]}"; do + mkdir -p "$DIR" + chmod -R 755 "$DIR" +done + +# Run httpd container +podman run -d --net host --name httpd-infra \ + --pod infra-pod \ + -v /opt/metal3-dev-env/ironic:/shared \ + -e PROVISIONING_INTERFACE=provisioning \ + -e LISTEN_ALL_INTERFACES=false \ + --entrypoint /bin/runhttpd \ + quay.io/metal3-io/ironic:latest + +# Generate ssh keys to use for virtual power and add them to authorized_keys +sudo ssh-keygen -f /root/.ssh/id_rsa_virt_power -P "" -q -y +sudo cat /root/.ssh/id_rsa_virt_power.pub | sudo tee -a /root/.ssh/authorized_keys + +# Create and start a container for sushy-tools +podman run -d --net host --name sushy-tools --pod infra-pod \ + -v "$SCRIPT_DIR/tmp/sushy-tools:/root/sushy" \ + -v /root/.ssh:/root/ssh \ + 127.0.0.1:5000/localimages/sushy-tools + +# Create and start a container for fake-ipa +podman run -d --net host --name fake-ipa --pod infra-pod \ + -v "$SCRIPT_DIR/tmp/cert:/root/cert" \ + -v /root/.ssh:/root/ssh \ + 127.0.0.1:5000/localimages/fake-ipa diff --git a/fake-ipa/Run-env/04-start-minikube.sh b/fake-ipa/Run-env/04-start-minikube.sh new file mode 100755 index 0000000..7caf726 --- /dev/null +++ b/fake-ipa/Run-env/04-start-minikube.sh @@ -0,0 +1,16 @@ +set -e + +# Start Minikube with insecure registry flag +minikube start --insecure-registry 172.22.0.1:5000 + +# SSH into the Minikube VM and execute the following commands +sudo su -l -c "minikube ssh sudo brctl addbr ironicendpoint" "${USER}" +sudo su -l -c "minikube ssh sudo ip link set ironicendpoint up" "${USER}" +sudo su -l -c "minikube ssh sudo brctl addif ironicendpoint eth2" "${USER}" +sudo su -l -c "minikube ssh sudo ip addr add 172.22.0.2/24 dev ironicendpoint" "${USER}" + +# Firewall rules +for i in 8000 80 9999 6385 5050 6180 53 5000; do sudo firewall-cmd --zone=public --add-port=${i}/tcp; done +for i in 8000 80 9999 6385 5050 6180 53 5000; do sudo firewall-cmd --zone=libvirt --add-port=${i}/tcp; done +for i in 69 547 546 68 67 5353 6230 6231 6232 6233 6234 6235; do sudo firewall-cmd --zone=libvirt --add-port=${i}/udp; done +sudo firewall-cmd --zone=libvirt --add-port=8000/tcp diff --git a/fake-ipa/Run-env/05-apply-manifests.sh b/fake-ipa/Run-env/05-apply-manifests.sh new file mode 100755 index 0000000..3171fcc --- /dev/null +++ b/fake-ipa/Run-env/05-apply-manifests.sh @@ -0,0 +1,78 @@ +set -e +# Apply ironic +# Clone bmo +if [ ! -d "baremetal-operator/" ] ; then + git clone "https://github.com/metal3-io/baremetal-operator.git" +fi + +cd baremetal-operator/ + +# Ironic config file +cat <<'EOF' >ironic-deployment/overlays/e2e/ironic_bmo_configmap.env +HTTP_PORT=6180 +PROVISIONING_IP=172.22.0.2 +DEPLOY_KERNEL_URL=http://172.22.0.2:6180/images/ironic-python-agent.kernel +DEPLOY_RAMDISK_URL=http://172.22.0.2:6180/images/ironic-python-agent.initramfs +IRONIC_ENDPOINT=https://172.22.0.2:6385/v1/ +CACHEURL=http://172.22.0.2/images +IRONIC_FAST_TRACK=true +IRONIC_KERNEL_PARAMS=console=ttyS0 +IRONIC_INSPECTOR_VLAN_INTERFACES=all +PROVISIONING_CIDR=172.22.0.1/24 +PROVISIONING_INTERFACE=ironicendpoint +DHCP_RANGE=172.22.0.10,172.22.0.100 +IRONIC_INSPECTOR_ENDPOINT=http://172.22.0.2:5050/v1/ +RESTART_CONTAINER_CERTIFICATE_UPDATED="false" +IRONIC_RAMDISK_SSH_KEY=ssh-rsa +IRONIC_USE_MARIADB=false +USE_IRONIC_INSPECTOR=false +OS_AGENT__REQUIRE_TLS=false +EOF + +# Apply default ironic manifest without tls or authentication for simplicity +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.1/cert-manager.yaml +kubectl -n cert-manager wait --for=condition=available deployment --all --timeout=300s + +echo "IRONIC_HTPASSWD=$(htpasswd -n -b -B admin password)" > ironic-deployment/overlays/e2e/ironic-htpasswd +echo "IRONIC_HTPASSWD=$(htpasswd -n -b -B admin password)" > ironic-deployment/overlays/e2e/ironic-inspector-htpasswd + +cat <<'EOF' >ironic-deployment/overlays/e2e/ironic-auth-config +[ironic] +auth_type=http_basic +username=admin +password=password +EOF + +kubectl apply -k config/namespace/ +kubectl apply -k ironic-deployment/overlays/e2e +cd .. + +kubectl -n baremetal-operator-system wait --for=condition=available deployment/ironic --timeout=300s + +sudo pip install python-ironicclient + + +kubectl -n legacy get secret -n baremetal-operator-system ironic-cert -o json -o=jsonpath="{.data.ca\.crt}" | base64 -d > "${HOME}/.config/openstack/ironic-ca.crt" + +sudo "${HOME}/.config/openstack/ironic-ca.crt" tmp/cert/ + +# Create ironic node +sudo touch /opt/metal3-dev-env/ironic/html/images/image.qcow2 + +baremetal node create --driver redfish --driver-info \ + redfish_address=http://192.168.111.1:8000 --driver-info \ + redfish_system_id=/redfish/v1/Systems/27946b59-9e44-4fa7-8e91-f3527a1ef094 --driver-info \ + redfish_username=admin --driver-info redfish_password=password \ + --name default-node-1 + +baremetal node set default-node-1 --driver-info deploy_kernel="http://172.22.0.2:6180/images/ironic-python-agent.kernel" --driver-info deploy_ramdisk="http://172.22.0.2:6180/images/ironic-python-agent.initramfs" +baremetal node set default-node-1 --instance-info image_source=http://172.22.0.1/images/image.qcow2 --instance-info image_checksum=http://172.22.0.1/images/image.qcow2 + +baremetal node create --driver redfish --driver-info \ +redfish_address=http://192.168.111.1:8000 --driver-info \ +redfish_system_id=/redfish/v1/Systems/27946b59-9e44-4fa7-8e91-f3527a1ef095 --driver-info \ +redfish_username=admin --driver-info redfish_password=password \ +--name default-node-2 + +baremetal node set default-node-2 --driver-info deploy_kernel="http://172.22.0.2:6180/images/ironic-python-agent.kernel" --driver-info deploy_ramdisk="http://172.22.0.2:6180/images/ironic-python-agent.initramfs" +baremetal node set default-node-2 --instance-info image_source=http://172.22.0.1/images/image.qcow2 --instance-info image_checksum=http://172.22.0.1/images/image.qcow2 \ No newline at end of file diff --git a/fake-ipa/Run-env/Init-environment.sh b/fake-ipa/Run-env/Init-environment.sh new file mode 100755 index 0000000..edbe270 --- /dev/null +++ b/fake-ipa/Run-env/Init-environment.sh @@ -0,0 +1,6 @@ +set -e +sudo ./01-vm-setup.sh +./02-configure-minikube.sh +sudo ./03-images-and-run-local-services.sh +./04-start-minikube.sh +./05-apply-manifests.sh \ No newline at end of file diff --git a/fake-ipa/Run-env/build-sushy-tools-and-fake-ipa-images.sh b/fake-ipa/Run-env/build-sushy-tools-and-fake-ipa-images.sh new file mode 100755 index 0000000..20bdee3 --- /dev/null +++ b/fake-ipa/Run-env/build-sushy-tools-and-fake-ipa-images.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +mkdir "${SCRIPT_DIR}/tmp" +SUSHYTOOLS_DIR="${SCRIPT_DIR}/tmp/sushy-tools" || true +rm -rf "$SUSHYTOOLS_DIR" +git clone https://opendev.org/openstack/sushy-tools.git "$SUSHYTOOLS_DIR" +cd "$SUSHYTOOLS_DIR" || exit +git fetch https://review.opendev.org/openstack/sushy-tools refs/changes/11/923111/10 && git cherry-pick FETCH_HEAD + +cd .. + +cat <"Dockerfile" +FROM docker.io/library/python:3.9 +RUN mkdir -p /root/sushy +COPY sushy-tools /root/sushy/sushy_tools +RUN apt update -y && \ + apt install -y python3 python3-pip python3-venv && \ + apt clean all +WORKDIR /root/sushy/sushy_tools +RUN python3 -m pip install . +RUN python3 setup.py install + +ENV FLASK_DEBUG=1 + + +CMD ["sushy-emulator", "-i", "::", "--config", "/root/sushy/conf.py"] +EOF + +sudo podman build -t 127.0.0.1:5000/localimages/sushy-tools . + +sudo podman build -t 127.0.0.1:5000/localimages/fake-ipa "${SCRIPT_DIR}/../" diff --git a/fake-ipa/Run-env/clean.sh b/fake-ipa/Run-env/clean.sh new file mode 100755 index 0000000..fad39e4 --- /dev/null +++ b/fake-ipa/Run-env/clean.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# Delete network connections +sudo nmcli con delete baremetal provisioning + +# Disable and delete bridge interfaces +for iface in baremetal provisioning; do + if ip link show $iface &>/dev/null; then + sudo ip link set $iface down + sudo brctl delbr $iface + fi +done + +# Delete libvirt networks +for net in provisioning baremetal; do + if sudo virsh net-info $net &>/dev/null; then + sudo virsh net-destroy $net + sudo virsh net-undefine $net + fi +done + +# Delete directories +sudo rm -rf /opt/metal3-dev-env +sudo rm -rf "$(dirname "$0")/_clouds_yaml" + +# Stop and delete minikube cluster +minikube stop +minikube delete --all --purge + +# Stop and delete containers +containers=("sushy-tools" "ironic-ipa-downloader" "ironic" "keepalived" "registry" "ironic-client" "httpd-infra" "fake-ipa") +for container in "${containers[@]}"; do + echo "Deleting the container: $container" + sudo podman stop "$container" &>/dev/null + sudo podman rm "$container" &>/dev/null +done diff --git a/fake-ipa/conf.py b/fake-ipa/conf.py new file mode 100644 index 0000000..5e5c801 --- /dev/null +++ b/fake-ipa/conf.py @@ -0,0 +1,37 @@ +SUSHY_EMULATOR_LIBVIRT_URI = "qemu+ssh://root@192.168.111.1/system?&keyfile=/root/ssh/id_rsa_virt_power&no_verify=1&no_tty=1" +SUSHY_EMULATOR_IGNORE_BOOT_DEVICE = False +SUSHY_EMULATOR_VMEDIA_VERIFY_SSL = False +SUSHY_EMULATOR_AUTH_FILE = "/root/sushy/htpasswd" +SUSHY_EMULATOR_FAKE_DRIVER = True +SUSHY_EMULATOR_FAKE_IPA = True +FAKE_IPA_API_URL = "https://172.22.0.2:6385" +FAKE_IPA_INSPECTION_CALLBACK_URL = "https://172.22.0.2:6385/v1/continue_inspection" +FAKE_IPA_ADVERTISE_ADDRESS_IP = "192.168.111.1" +FAKE_IPA_INSECURE = False +FAKE_IPA_CAFILE = "/root/cert/ironic-ca.crt" +SUSHY_EMULATOR_FAKE_SYSTEMS = [ + { + 'uuid': '27946b59-9e44-4fa7-8e91-f3527a1ef094', + 'name': 'fake1', + 'power_state': 'Off', + 'external_notifier': True, + 'nics': [ + { + 'mac': '00:5c:52:31:3a:9c', + 'ip': '172.22.0.100' + } + ] + }, + { + 'uuid': '27946b59-9e44-4fa7-8e91-f3527a1ef095', + 'name': 'fake2', + 'power_state': 'Off', + 'external_notifier': True, + 'nics': [ + { + 'mac': '00:5c:52:31:3a:9d', + 'ip': '172.22.0.101' + } + ] + } + ] \ No newline at end of file diff --git a/fake-ipa/fake_ipa/__init__.py b/fake-ipa/fake_ipa/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fake-ipa/fake_ipa/base.py b/fake-ipa/fake_ipa/base.py new file mode 100644 index 0000000..faf1057 --- /dev/null +++ b/fake-ipa/fake_ipa/base.py @@ -0,0 +1,346 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import collections +import functools +from importlib import import_module +import inspect +import logging +import random +import threading +import time +import uuid + +from fake_ipa import encoding +from fake_ipa import error + + +LOG = logging.getLogger(__name__) + + +class BaseAgentExtension: + def __init__(self, agent=None): + self.agent = agent + self.command_map = dict( + (v.command_name, v) + for _, v in inspect.getmembers(self) + if hasattr(v, 'command_name') + ) + + def execute(self, command_name, **kwargs): + cmd = self.command_map.get(command_name) + if cmd is None: + raise error.InvalidCommandError( + 'Unknown command: {}'.format(command_name)) + return cmd(**kwargs) + + def check_cmd_presence(self, ext_obj, ext, cmd): + if not (hasattr(ext_obj, 'execute') + and hasattr(ext_obj, 'command_map') + and cmd in ext_obj.command_map): + raise error.InvalidCommandParamsError( + "Extension {} doesn't provide {} method".format(ext, cmd)) + + def fake_processing_delay(self, min, max): + processing_time = random.randint(min, max) + time.sleep(processing_time) + + +class AgentCommandStatus(object): + """Mapping of agent command statuses.""" + RUNNING = 'RUNNING' + SUCCEEDED = 'SUCCEEDED' + FAILED = 'FAILED' + # TODO(dtantsur): keeping the same text for backward compatibility, change + # to just VERSION_MISMATCH one release after ironic is updated. + VERSION_MISMATCH = 'CLEAN_VERSION_MISMATCH' + + +class BaseCommandResult(encoding.SerializableComparable): + """Base class for command result.""" + + serializable_fields = ('id', 'command_name', + 'command_status', 'command_error', 'command_result') + + def __init__(self, command_name, command_params): + """Construct an instance of BaseCommandResult. + + :param command_name: name of command executed + :param command_params: parameters passed to command + """ + + self.id = str(uuid.uuid4()) + self.command_name = command_name + self.command_params = command_params + self.command_status = AgentCommandStatus.RUNNING + self.command_error = None + self.command_result = None + + def __str__(self): + return ("Command name: %(name)s, " + "params: %(params)s, status: %(status)s, result: " + "%(result)s." % + {"name": self.command_name, + "params": self.command_params, + "status": self.command_status, + "result": self.command_result}) + + def is_done(self): + """Checks to see if command is still RUNNING. + + :returns: True if command is done, False if still RUNNING + """ + return self.command_status != AgentCommandStatus.RUNNING + + def join(self): + """:returns: result of completed command.""" + return self + + def wait(self): + """Join the result and extract its value. + + Raises if the command failed. + """ + self.join() + if self.command_error is not None: + raise self.command_error + else: + return self.command_result + + +class SyncCommandResult(BaseCommandResult): + """A result from a command that executes synchronously.""" + + def __init__(self, command_name, command_params, success, result_or_error): + """Construct an instance of SyncCommandResult. + + :param command_name: name of command executed + :param command_params: parameters passed to command + :param success: True indicates success, False indicates failure + :param result_or_error: Contains the result (or error) from the command + """ + + super(SyncCommandResult, self).__init__(command_name, + command_params) + if isinstance(result_or_error, (bytes, str)): + result_key = 'result' if success else 'error' + result_or_error = {result_key: result_or_error} + + if success: + self.command_status = AgentCommandStatus.SUCCEEDED + self.command_result = result_or_error + else: + self.command_status = AgentCommandStatus.FAILED + self.command_error = result_or_error + + +class AsyncCommandResult(BaseCommandResult): + """A command that executes asynchronously in the background.""" + + def __init__(self, command_name, command_params, execute_method, + agent=None): + """Construct an instance of AsyncCommandResult. + + :param command_name: name of command to execute + :param command_params: parameters passed to command + :param execute_method: a callable to be executed asynchronously + :param agent: Optional: an instance of IronicPythonAgent + """ + + super(AsyncCommandResult, self).__init__(command_name, command_params) + self.agent = agent + self.execute_method = execute_method + self.time = time.time() + random.randint(5, 10) + + def join(self, timeout=None): + """Block until command has completed, and return result. + + :param timeout: float indicating max seconds to wait for command + to complete. Defaults to None. + """ + time.sleep(max(0, self.time - time.time())) + return self + + def run(self): + """Run a command.""" + + try: + result = self.execute_method(**self.command_params) + self.command_result = result + self.command_status = AgentCommandStatus.SUCCEEDED + + except Exception as e: + LOG.exception('Command failed: %(name)s, error: %(err)s', + {'name': self.command_name, 'err': e}) + if not isinstance(e, error.RESTError): + e = error.CommandExecutionError(str(e)) + + self.command_error = e + self.command_status = AgentCommandStatus.FAILED + finally: + if self.agent: + self.agent.force_heartbeat() + + +class ExecuteCommandMixin(object): + def __init__(self): + self.command_lock = threading.Lock() + self.command_results = collections.OrderedDict() + + def get_extension(self, extension_name): + + extensions_list = { + "standby": "fake_ipa.standby.StandbyExtension", + "clean": "fake_ipa.clean.CleanExtension", + "deploy": "fake_ipa.deploy.DeployExtension", + "image": "fake_ipa.image.ImageExtension", + "log": "fake_ipa.log.LogExtension" + } + + if extension_name not in extensions_list: + raise error.ExtensionError( + 'Extension %s does not exist !', extension_name + ) + try: + ext_path, ext_class = extensions_list[extension_name].rsplit( + ".", 1 + ) + except ValueError: + raise error.ExtensionError( + '%s extension path error' % extensions_list[extension_name] + ) + + module = import_module(ext_path) + return getattr(module, ext_class)() + + def split_command(self, command_name): + command_parts = command_name.split('.', 1) + if len(command_parts) != 2: + raise error.InvalidCommandError( + 'Command name must be of the form .') + + return (command_parts[0], command_parts[1]) + + def refresh_last_async_command(self): + if len(self.command_results) > 0: + last_command = list(self.command_results.values())[-1] + if not last_command.is_done() and time.time() >= last_command.time: + last_command.run() + + def execute_command(self, command_name, **kwargs): + """Execute an agent command.""" + self.refresh_last_async_command() + LOG.debug( + 'Executing command: %(name)s with args: %(args)s', + { + 'name': command_name, + 'args': kwargs + }) + extension_part, command_part = self.split_command(command_name) + + if len(self.command_results) > 0: + last_command = list(self.command_results.values())[-1] + if not last_command.is_done(): + LOG.error( + 'Tried to execute %(command)s, agent is still ' + 'executing %(last)s', + { + 'command': command_name, + 'last': last_command + }) + raise error.AgentIsBusy(last_command.command_name) + + try: + ext = self.get_extension(extension_part) + result = ext.execute(command_part, **kwargs) + except KeyError: + # Extension Not found + LOG.exception('Extension %s not found', extension_part) + raise error.RequestedObjectNotFoundError( + 'Extension', + extension_part) + except error.InvalidContentError as e: + # Any command may raise a InvalidContentError which will be + # returned to the caller directly. + LOG.exception('Invalid content error: %s', e) + raise e + except Exception as e: + # Other errors are considered command execution errors, and are + # recorded as a failed SyncCommandResult with an error message + LOG.exception('Command execution error: %s', e) + result = SyncCommandResult(command_name, kwargs, False, e) + self.command_results[result.id] = result + return result + + +def async_command(command_name, validator=None): + """Will run the command in an AsyncCommandResult in its own thread. + + command_name is set based on the func name and command_params will + be whatever args/kwargs you pass into the decorated command. + Return values of type `str` or `unicode` are prefixed with the + `command_name` parameter when returned for consistency. + """ + + def async_decorator(func): + func.command_name = command_name + + @functools.wraps(func) + def wrapper(self, **command_params): + # Run a validator before passing everything off to async. + # validators should raise exceptions or return silently. + if validator: + validator(self, **command_params) + + # bind self to func so that AsyncCommandResult doesn't need to + # know about the mode + bound_func = functools.partial(func, self) + ret = AsyncCommandResult(command_name, + command_params, + bound_func, + agent=self.agent) + LOG.info('Asynchronous command %(name)s started execution', + {'name': command_name}) + return ret + return wrapper + return async_decorator + + +def sync_command(command_name, validator=None): + """Decorate a method to wrap its return value in a SyncCommandResult. + + For consistency with @async_command() can also accept a + validator which will be used to validate input, although a synchronous + command can also choose to implement validation inline. + """ + + def sync_decorator(func): + func.command_name = command_name + + @functools.wraps(func) + def wrapper(self, **command_params): + # Run a validator before invoking the function. + # validators should raise exceptions or return silently. + if validator: + validator(self, **command_params) + + result = func(self, **command_params) + LOG.info('Synchronous command %(name)s completed: %(result)s', + {'name': command_name, + 'result': result}) + return SyncCommandResult(command_name, + command_params, + True, + result) + + return wrapper + return sync_decorator diff --git a/fake-ipa/fake_ipa/clean.py b/fake-ipa/fake_ipa/clean.py new file mode 100644 index 0000000..79ee190 --- /dev/null +++ b/fake-ipa/fake_ipa/clean.py @@ -0,0 +1,89 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from fake_ipa import base + +LOG = logging.getLogger(__name__) + +clean_steps = { + "GenericHardwareManager": [ + { + "step": "erase_devices", + "priority": 10, + "interface": "deploy", + "reboot_requested": False, + "abortable": True + }, + { + "step": "erase_devices_metadata", + "priority": 99, + "interface": "deploy", + "reboot_requested": False, + "abortable": True + } + ] +} + + +class CleanExtension(base.BaseAgentExtension): + @base.sync_command('get_clean_steps') + def get_clean_steps(self, node, ports): + """Get the list of clean steps supported for the node and ports + + :param node: A dict representation of a node + :param ports: A dict representation of ports attached to node + + :returns: A list of clean steps with keys step, priority, and + reboot_requested + """ + LOG.debug('Getting clean steps, called with node: %(node)s, ' + 'ports: %(ports)s', {'node': node, 'ports': ports}) + return { + 'clean_steps': clean_steps, + 'hardware_manager_version': { + "fake_hardware_manager": "1.1" + }, + } + + @base.async_command('execute_clean_step') + def execute_clean_step(self, step, node, ports, clean_version=None, + **kwargs): + """Execute a clean step. + + :param step: A clean step with 'step', 'priority' and 'interface' keys + :param node: A dict representation of a node + :param ports: A dict representation of ports attached to node + :param clean_version: The clean version as returned by + hardware.get_current_versions() at the beginning + of cleaning/zapping + :returns: a CommandResult object with command_result set to whatever + the step returns. + """ + # Ensure the agent is still the same version, or raise an exception + LOG.debug('Executing clean step %s', step) + if 'step' not in step: + msg = 'Malformed clean_step, no "step" key: %s' % step + LOG.error(msg) + raise ValueError(msg) + kwargs.update(step.get('args') or {}) + result = {} + LOG.info('Clean step completed: %(step)s, result: %(result)s', + {'step': step, 'result': result}) + + # Return the step that was executed so we can dispatch + # to the appropriate Ironic interface + return { + 'clean_result': result, + 'clean_step': step + } diff --git a/fake-ipa/fake_ipa/deploy.py b/fake-ipa/fake_ipa/deploy.py new file mode 100644 index 0000000..3e07088 --- /dev/null +++ b/fake-ipa/fake_ipa/deploy.py @@ -0,0 +1,85 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from fake_ipa import base + +LOG = logging.getLogger(__name__) + +deploy_steps = { + "GenericHardwareManager": [ + { + "step": "write_image", + "priority": 0, + "interface": "deploy", + "reboot_requested": False + } + ] +} + + +class DeployExtension(base.BaseAgentExtension): + @base.sync_command('get_deploy_steps') + def get_deploy_steps(self, node, ports): + """Get the list of deploy steps supported for the node and ports + + :param node: A dict representation of a node + :param ports: A dict representation of ports attached to node + + :returns: A list of deploy steps with keys step, priority, and + reboot_requested + """ + LOG.debug('Getting deploy steps, called with node: %(node)s, ' + 'ports: %(ports)s', {'node': node, 'ports': ports}) + return { + 'deploy_steps': deploy_steps, + 'hardware_manager_version': { + "generic_hardware_manager": "1.1" + }, + } + + @base.async_command('execute_deploy_step') + def execute_deploy_step(self, step, node, ports, deploy_version=None, + **kwargs): + """Execute a deploy step. + + :param step: A deploy step with 'step', 'priority' and 'interface' keys + :param node: A dict representation of a node + :param ports: A dict representation of ports attached to node + :param deploy_version: The deploy version as returned by + hardware.get_current_versions() at the beginning + of deploying. + :param kwargs: The remaining arguments are passed to the step. + :returns: a CommandResult object with command_result set to whatever + the step returns. + """ + # Ensure the agent is still the same version, or raise an exception + LOG.debug('Executing deploy step %s', step) + + if 'step' not in step: + msg = 'Malformed deploy_step, no "step" key: %s' % step + LOG.error(msg) + raise ValueError(msg) + + kwargs.update(step.get('args') or {}) + result = {} + + LOG.info('Deploy step completed: %(step)s, result: %(result)s', + {'step': step, 'result': result}) + + # Return the step that was executed so we can dispatch + # to the appropriate Ironic interface + return { + 'deploy_result': result, + 'deploy_step': step + } diff --git a/fake-ipa/fake_ipa/encoding.py b/fake-ipa/fake_ipa/encoding.py new file mode 100644 index 0000000..c55daba --- /dev/null +++ b/fake-ipa/fake_ipa/encoding.py @@ -0,0 +1,79 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import uuid + + +class Serializable(object): + """Base class for things that can be serialized.""" + serializable_fields = () + + def serialize(self): + """Turn this object into a dict.""" + return dict((f, getattr(self, f)) for f in self.serializable_fields) + + +class SerializableComparable(Serializable): + """A Serializable class which supports some comparison operators + + This class supports the '__eq__' and '__ne__' comparison operators, but + intentionally disables the '__hash__' operator as some child classes may be + mutable. The addition of these comparison operators is mainly used to + assist with unit testing. + """ + + __hash__ = None + + def __eq__(self, other): + return self.serialize() == other.serialize() + + def __ne__(self, other): + return self.serialize() != other.serialize() + + +def serialize_lib_exc(exc): + """Serialize an ironic-lib exception.""" + return {'type': exc.__class__.__name__, + 'code': exc.code, + 'message': str(exc), + 'details': ''} + + +class RESTJSONEncoder(json.JSONEncoder): + """A slightly customized JSON encoder.""" + + def encode(self, o): + """Turn an object into JSON. + + Appends a newline to responses when configured to pretty-print, + in order to make use of curl less painful from most shells. + """ + delimiter = '' + + # if indent is None, newlines are still inserted, so we should too. + if self.indent is not None: + delimiter = '\n' + + return super(RESTJSONEncoder, self).encode(o) + delimiter + + def default(self, o): + """Turn an object into a serializable object. + + In particular, by calling :meth:`.Serializable.serialize` on `o`. + """ + if isinstance(o, Serializable): + return o.serialize() + elif isinstance(o, uuid.UUID): + return str(o) + else: + return json.JSONEncoder.default(self, o) diff --git a/fake-ipa/fake_ipa/error.py b/fake-ipa/fake_ipa/error.py new file mode 100644 index 0000000..93be877 --- /dev/null +++ b/fake-ipa/fake_ipa/error.py @@ -0,0 +1,225 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from fake_ipa import encoding + + +class FishyError(Exception): + """Create generic sushy-tools exception object""" + + def __init__(self, msg='Unknown error', code=500): + super().__init__(msg) + self.code = code + + +class AliasAccessError(FishyError): + """Node access attempted via an alias, not UUID""" + + +class NotSupportedError(FishyError): + """Feature not supported by resource driver""" + + def __init__(self, msg='Unsupported'): + super().__init__(msg) + + +class NotFound(FishyError): + """Entity not found.""" + + def __init__(self, msg='Not found', code=404): + super().__init__(msg, code) + + +class BadRequest(FishyError): + """Malformed request.""" + + def __init__(self, msg, code=400): + super().__init__(msg, code) + + +class FeatureNotAvailable(NotFound): + """Feature is not available.""" + + def __init__(self, feature, code=404): + super().__init__(f"Feature {feature} not available", code=code) + + +class Conflict(FishyError): + """Conflict with current state of the resource.""" + + def __init__(self, msg, code=409): + super().__init__(msg, code) + +class LookupNodeError(Exception): + """Error raised when the node lookup to the Ironic API fails.""" + + def __init__(self, msg='Error getting configuration from Ironic'): + super().__init__(msg) + + +class RESTError(Exception, encoding.Serializable): + """Base class for errors generated in ironic-python-client.""" + message = 'An error occurred' + details = 'An unexpected error occurred. Please try back later.' + status_code = 500 + serializable_fields = ('type', 'code', 'message', 'details') + + def __init__(self, details=None, *args, **kwargs): + super(RESTError, self).__init__(*args, **kwargs) + self.type = self.__class__.__name__ + self.code = self.status_code + if details: + self.details = details + + def __str__(self): + return "{}: {}".format(self.message, self.details) + + def __repr__(self): + """Should look like RESTError('message: details')""" + return "{}('{}')".format(self.__class__.__name__, self.__str__()) + + +class IronicAPIError(RESTError): + """Error raised when a call to the agent API fails.""" + + message = 'Error in call to ironic-api' + + def __init__(self, details): + super(IronicAPIError, self).__init__(details) + + +class NodeUUIDError(IronicAPIError): + """Error raised when UUID key does not exist in agents dict.""" + + message = 'Error UUID does not exist' + + def __init__(self, details): + super(NodeUUIDError, self).__init__(details) + + +class HeartbeatError(IronicAPIError): + """Error raised when a heartbeat to the agent API fails.""" + + message = 'Error heartbeating to agent API' + + def __init__(self, details): + super(HeartbeatError, self).__init__(details) + + +class HeartbeatNotFoundError(IronicAPIError): + """Error raised when a heartbeat to the agent API fails.""" + + message = 'Error heartbeating to agent API' + + def __init__(self, details): + super(HeartbeatNotFoundError, self).__init__(details) + + +class HeartbeatConflictError(IronicAPIError): + """ConflictError raised when a heartbeat to the agent API fails.""" + + message = 'ConflictError heartbeating to agent API' + + def __init__(self, details): + super(HeartbeatConflictError, self).__init__(details) + + +class HeartbeatConnectionError(IronicAPIError): + """Transitory connection failure occured attempting to contact the API.""" + + message = ("Error attempting to heartbeat - Possible transitory network " + "failure or blocking port may be present.") + + def __init__(self, details): + super(HeartbeatConnectionError, self).__init__(details) + + +class CommandExecutionError(RESTError): + """Error raised when a command fails to execute.""" + + message = 'Command execution failed' + + def __init__(self, details): + super(CommandExecutionError, self).__init__(details) + + +class AgentIsBusy(CommandExecutionError): + + message = 'Agent is busy' + status_code = 409 + + def __init__(self, command_name): + super().__init__('executing command %s' % command_name) + + +class RequestedObjectNotFoundError(NotFound): + def __init__(self, type_descr, obj_id): + details = '{} with id {} not found.'.format(type_descr, obj_id) + super(RequestedObjectNotFoundError, self).__init__(details) + + +class InvalidContentError(RESTError): + """Error which occurs when a user supplies invalid content. + + Either because that content cannot be parsed according to the advertised + `Content-Type`, or due to a content validation error. + """ + + message = 'Invalid request body' + status_code = 400 + + def __init__(self, details): + super(InvalidContentError, self).__init__(details) + + +class ExtensionError(RESTError): + pass + + +class InvalidCommandError(InvalidContentError): + """Error which is raised when an unknown command is issued.""" + + message = 'Invalid command' + + def __init__(self, details): + super(InvalidCommandError, self).__init__(details) + + +class InvalidCommandParamsError(InvalidContentError): + """Error which is raised when command parameters are invalid.""" + + message = 'Invalid command parameters' + + def __init__(self, details): + super(InvalidCommandParamsError, self).__init__(details) + + +class VersionMismatch(RESTError): + """Error raised when Ironic and the Agent have different versions. + + If the agent version has changed since get_clean_steps or get_deploy_steps + was called by the Ironic conductor, it indicates the agent has been updated + (either on purpose, or a new agent was deployed and the node was rebooted). + Since we cannot know if the upgraded IPA will work with cleaning/deploy as + it stands (steps could have different priorities, either in IPA or in + other Ironic interfaces), we should restart the process from the start. + """ + message = ( + 'Hardware managers version mismatch, reload agent with correct version' + ) + + def __init__(self, agent_version, node_version): + self.status_code = 409 + details = ('Current versions: {}, versions used by ironic: {}' + .format(agent_version, node_version)) + super(VersionMismatch, self).__init__(details) diff --git a/fake-ipa/fake_ipa/fake_agent.py b/fake-ipa/fake_ipa/fake_agent.py new file mode 100644 index 0000000..21e9727 --- /dev/null +++ b/fake-ipa/fake_ipa/fake_agent.py @@ -0,0 +1,180 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import random +import time + + +from fake_ipa import base +from fake_ipa import error +from fake_ipa.heartbeater import Heatbeater +from fake_ipa import inspector +from fake_ipa.ironic_api_client import APIClient +from fake_ipa.ironic_api_client import get_ssl_client_options + + +class FakeIronicPythonAgent(base.ExecuteCommandMixin): + """Class for faking ipa functionality.""" + + agent_token = None + + @classmethod + def initialize(cls, config, logger, api): + config.setdefault('FAKE_IPA_INSPECTION_CALLBACK_URL', + 'http://localhost:5050/v1/continue') + config.setdefault('FAKE_IPA_MIN_BOOT_TIME', 180) + config.setdefault('FAKE_IPA_MAX_BOOT_TIME', 240) + cls._config = config + cls._logger = logger + cls.api = api + Heatbeater.initialize(config, logger).run_heartbeater_threads(2) + return cls + + def __init__(self, system, api_url, + ip_lookup_attempts=6, ip_lookup_sleep=10, + lookup_timeout=300, lookup_interval=1): + super(FakeIronicPythonAgent, self).__init__() + self.system = system + self.api_url = api_url + if self.api_url: + self.api_client = APIClient.initialize( + self._config, self._logger)(self.system, self.api_url) + self.heartbeater = Heatbeater() + self.lookup_timeout = lookup_timeout + self.lookup_interval = lookup_interval + self.ip_lookup_attempts = ip_lookup_attempts + self.ip_lookup_sleep = ip_lookup_sleep + + def boot(self): + + # Waiting for ironic to unlock the node after changing the power state + time.sleep(random.randint( + self._config["FAKE_IPA_MIN_BOOT_TIME"], + self._config["FAKE_IPA_MAX_BOOT_TIME"])) + + uuid = None + verify, cert = get_ssl_client_options(self._config) + if self._config["FAKE_IPA_INSPECTION_CALLBACK_URL"]: + self._logger.debug( + "Starting inspection node %s and sending data to %s", + self.system["name"], + self._config["FAKE_IPA_INSPECTION_CALLBACK_URL"]) + try: + uuid = inspector.inspect( + self.system, + self._config["FAKE_IPA_INSPECTION_CALLBACK_URL"], + verify, cert, + self._logger) + except Exception as exc: + self._logger.error('Failed to perform inspection: %s', exc) + self._logger.debug("Inspection UUID %s", uuid) + + if self.api_url: + content = self.api_client.lookup_node( + timeout=self.lookup_timeout, + starting_interval=self.lookup_interval, + node_uuid=uuid) + self._logger.debug('Received lookup results: %s', content) + self.process_lookup_data(content) + + elif self._config["FAKE_IPA_INSPECTION_CALLBACK_URL"]: + self._logger.info( + 'No FAKE_IPA_API_URL configured,' + 'Heartbeat and lookup' + 'skipped for inspector.') + else: + self._logger.error( + 'Neither FAKE_IPA_API_URL nor' + 'FAKE_IPA_INSPECTION_CALLBACK_URL' + 'found, please check your' + 'pxe append parameters.') + + if self.api_url: + # Add the new node to heartbeater queue + self._logger.info( + 'Adding Node %s to the heartbeater queue', self.system['uuid']) + Heatbeater.add_to_q(self.system, self) + + def process_lookup_data(self, content): + """Update agent configuration from lookup data.""" + + # This is a different data from what we init the node + self.node = content['node'] + self._logger.info('Lookup succeeded, node UUID is %s', + self.node['uuid']) + FakeIronicPythonAgent.api.agents[self.node['uuid']] = self + self.heartbeat_timeout = content['config']['heartbeat_timeout'] + # Update config with values from Ironic + config = content.get('config', {}) + if config.get('agent_token_required'): + self.agent_token_required = True + token = config.get('agent_token') + if token: + if len(token) >= 32: + self._logger.debug('Agent token recorded as designated by ' + 'the ironic installation.') + self.agent_token = token + # set with-in the API client. + self.api_client.agent_token = token + elif token == '******': + self._logger.error('The agent token has already been ' + 'retrieved. IPA may not operate as ' + 'intended and the deployment may fail ' + 'depending on settings in the ironic ' + 'deployment.') + if not self.agent_token and self.agent_token_required: + self._logger.error('Ironic is signaling that agent tokens ' + 'are required, however we do not have ' + 'a token on file. ' + 'This is likely **FATAL**.') + else: + self._logger.info('An invalid token was received.') + if self.agent_token: + # Explicitly set the token in our API client before + # starting heartbeat operations. + self.api_client.agent_token = self.agent_token + + def force_heartbeat(self): + self.heartbeater.force_heartbeat() + + def list_command_results(self): + """Get a list of command results. + + :returns: list of :class:`fake_ipa.extensions.base. + BaseCommandResult` objects. + """ + self.refresh_last_async_command() + return list(self.command_results.values()) + + def get_command_result(self, result_id): + """Get a specific command result by ID. + + :returns: a :class:`fake_ipa.extensions.base. + BaseCommandResult` object. + :raises: RequestedObjectNotFoundError if command with the given ID + is not found. + """ + + try: + return self.command_results[result_id] + except KeyError: + raise error.RequestedObjectNotFoundError('Command Result', + result_id) + + def validate_agent_token(self, token): + # We did not get a token, i.e. None and + # we've previously seen a token, which is + # a mid-cluster upgrade case with long-running ramdisks. + if (not token and self.agent_token + and not self.agent_token_required): + return True + return self.agent_token == token diff --git a/fake-ipa/fake_ipa/heartbeater.py b/fake-ipa/fake_ipa/heartbeater.py new file mode 100644 index 0000000..eb1d4e2 --- /dev/null +++ b/fake-ipa/fake_ipa/heartbeater.py @@ -0,0 +1,167 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import collections +import random +from threading import currentThread +from threading import Thread +import time + +from fake_ipa import error + +Host = collections.namedtuple('Host', ['hostname', 'port']) + + +class Heatbeater: + + queue = collections.deque() + remove_from_q = set() + interval = 0 + heartbeat_forced = False + + @classmethod + def initialize(cls, config, logger): + cls._config = config + cls._logger = logger + return cls + + # If we could wait at most N seconds between heartbeats (or in case of an + # error) we will instead wait r x N seconds, where r is a random value + # between these multipliers. + min_jitter_multiplier = 0.3 + max_jitter_multiplier = 0.6 + min_interval = 5 + + def heartbeat(self): + while True: + + try: + (system, agent, previous_heartbeat) = \ + Heatbeater.queue.popleft() + if system['uuid'] in Heatbeater.remove_from_q: + self._logger.info( + 'Thread[%s] Removing.. %s ', currentThread().ident, + system['name']) + Heatbeater.remove_from_q.remove(system['uuid']) + Heatbeater.min_interval = 5 + continue + except IndexError: + # empty q default min interval supposing: + # len(thread) << len(nodes) + # else thread q != no node in heartbeater + Heatbeater.min_interval = 5 + time.sleep(Heatbeater.min_interval) + continue + + if self._heartbeat_expected(agent, previous_heartbeat): + self._logger.debug( + 'Thread[%s] Currently processing %s' + '[%s] and %s ', + currentThread().ident, system['name'], + system['uuid'], Heatbeater.printq()) + self.do_heartbeat(system, agent) + Heatbeater.queue.append((system, agent, time.time())) + else: + + Heatbeater.queue.append((system, agent, previous_heartbeat)) + + time.sleep(self.min_interval) + + def _heartbeat_expected(self, agent, previous_heartbeat): + # Normal heartbeating + if time.time() > previous_heartbeat + agent.heartbeater.interval: + return True + + # Forced heartbeating, but once in 5 seconds + if (agent.heartbeater.heartbeat_forced + and time.time() > previous_heartbeat + 5): + return True + + def do_heartbeat(self, system, agent): + """Send a heartbeat to Ironic.""" + try: + agent.api_client.heartbeat( + uuid=agent.node['uuid'], + advertise_address=Host( + hostname=self._config['FAKE_IPA_ADVERTISE_ADDRESS_IP'], + port=self._config['FAKE_IPA_ADVERTISE_ADDRESS_PORT']), + advertise_protocol="http", + generated_cert=None, + ) + self._logger.info('heartbeat successful') + agent.heartbeater.heartbeat_forced = False + self.previous_heartbeat = time.time() + except error.HeartbeatConflictError: + self._logger.warning('conflict error sending heartbeat to %s', + agent.api_url) + except error.HeartbeatNotFoundError: + self._logger.warning( + 'not found error removing the node from' + 'heartbeater q %s', + system["uuid"]) + Heatbeater.remove_from_heartbeater_q(system["uuid"]) + except Exception: + self._logger.exception( + 'error sending heartbeat to %s', agent.api_url) + finally: + interval_multiplier = random.uniform( + agent.heartbeater.min_jitter_multiplier, + agent.heartbeater.max_jitter_multiplier) + agent.heartbeater.interval = \ + agent.heartbeat_timeout * interval_multiplier + Heatbeater.min_interval = min(agent.heartbeater.interval, 5) + self._logger.info( + 'sleeping before next heartbeat, interval: %s' + '(min interval %s)', + agent.heartbeater.interval, + Heatbeater.min_interval) + + def force_heartbeat(self): + self.heartbeat_forced = True + + @classmethod + def remove_from_heartbeater_q(cls, uuid): + # to avoid conflicts with threads only the threads heartbeating + # the node can remove it in this method we create a temporary list + # for the nodes to be removed + # TODO(Mohammed) add expire time so if node left out in this list + # we clean them periodically + Heatbeater._logger.info("Added to remove list %s", uuid) + Heatbeater.remove_from_q.add(uuid) + + @classmethod + def printq(cls): + _l = [] + for _q in Heatbeater.queue: + node_name = _q[0]['name'] + time_left = _q[2] + \ + _q[1].heartbeater.interval - time.time() + if _q[0]['uuid'] in Heatbeater.remove_from_q: + node_name = "X" + node_name + "X" + _l.append("{0} <- {1:.0f}s".format(node_name, time_left)) + return {"Q": _l, "To be removed": Heatbeater.remove_from_q} + + @classmethod + def add_to_q(cls, system, agent): + # when we inspect an on node it will be added to removing list before + # turning off + if system['uuid'] in Heatbeater.remove_from_q: + Heatbeater.remove_from_q.remove(system['uuid']) + Heatbeater.queue.append((system, agent, time.time())) + + @classmethod + def run_heartbeater_threads(cls, nb_threads): + # Setup the heartbeater threads + threads = [Thread(target=Heatbeater().heartbeat, daemon=True) + for _ in range(nb_threads)] + for t in threads: + t.start() diff --git a/fake-ipa/fake_ipa/image.py b/fake-ipa/fake_ipa/image.py new file mode 100644 index 0000000..89df048 --- /dev/null +++ b/fake-ipa/fake_ipa/image.py @@ -0,0 +1,42 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from fake_ipa import base + +LOG = logging.getLogger(__name__) + + +class ImageExtension(base.BaseAgentExtension): + + @base.async_command('install_bootloader') + def install_bootloader(self, root_uuid, efi_system_part_uuid=None, + prep_boot_part_uuid=None, + target_boot_mode='bios', + ignore_bootloader_failure=None): + """Install the GRUB2 bootloader on the image. + + :param root_uuid: The UUID of the root partition. + :param efi_system_part_uuid: The UUID of the efi system partition. + To be used only for uefi boot mode. For uefi boot mode, the + boot loader will be installed here. + :param prep_boot_part_uuid: The UUID of the PReP Boot partition. + Used only for booting ppc64* partition images locally. In this + scenario the bootloader will be installed here. + :param target_boot_mode: bios, uefi. Only taken into account + for softraid, when no efi partition is explicitely provided + (happens for whole disk images) + + """ + LOG.debug('Executing image install_bootloader') + return diff --git a/fake-ipa/fake_ipa/inspector.py b/fake-ipa/fake_ipa/inspector.py new file mode 100644 index 0000000..3afd1eb --- /dev/null +++ b/fake-ipa/fake_ipa/inspector.py @@ -0,0 +1,64 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import requests +import tenacity + +_RETRY_WAIT = 5 +_RETRY_ATTEMPTS = 5 + +# FIXME fix passing the logger as parameter + + +def inspect(system, inspection_callback_url, verify, cert, logger): + + data = { + "boot_interface": system.get("nics")[0]["mac"], + "inventory": { + "interfaces": [ + { + "lldp": None, + "product": "0x0001", + "vendor": "0x1af4", + "name": "eth1", + "has_carrier": True, + "ipv4_address": system.get("nics")[0]["ip"] or None, + "client_id": None, + "mac_address": system.get("nics")[0]["mac"], + } + ], + "cpu": { + "count": 2, + "frequency": "2100.084", + "flags": ["fpu", "mmx", "fxsr", "sse", "sse2"], + "architecture": "x86_64", + }, + }, + } + + @tenacity.retry( + retry=tenacity.retry_if_exception_type( + requests.exceptions.ConnectionError), + stop=tenacity.stop_after_attempt(_RETRY_ATTEMPTS), + wait=tenacity.wait_fixed(_RETRY_WAIT), + reraise=True) + def _post_to_inspector(): + return requests.post(inspection_callback_url, verify=verify, cert=cert, json=data) + + resp = _post_to_inspector() + if resp.status_code >= 400: + logger.error('inspector %s error %d: %s, proceeding with lookup', + inspection_callback_url, + resp.status_code, resp.content.decode('utf-8')) + return + + return resp.json().get('uuid') diff --git a/fake-ipa/fake_ipa/ironic_api_client.py b/fake-ipa/fake_ipa/ironic_api_client.py new file mode 100644 index 0000000..cb70a26 --- /dev/null +++ b/fake-ipa/fake_ipa/ironic_api_client.py @@ -0,0 +1,268 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json + +import requests +import tenacity + +from fake_ipa import encoding +from fake_ipa import error + +MIN_IRONIC_VERSION = (1, 31) +AGENT_VERSION_IRONIC_VERSION = (1, 36) +AGENT_TOKEN_IRONIC_VERSION = (1, 62) +AGENT_VERIFY_CA_IRONIC_VERSION = (1, 68) +MAX_KNOWN_VERSION = AGENT_VERIFY_CA_IRONIC_VERSION +# TODO(Mohammed) FIX to a correct version +# Add a parameter to set ipa version +__version__ = "1.22" + + +class APIClient(): + + api_version = 'v1' + lookup_api = '/%s/lookup' % api_version + heartbeat_api = '/%s/heartbeat/{uuid}' % api_version + _ironic_api_version = None + agent_token = None + + @classmethod + def initialize(cls, config, logger): + cls._logger = logger + cls._config = config + return cls + + def __init__(self, node, api_url): + self.api_url = api_url.rstrip('/') + self.node = node + + # Only keep alive a maximum of 2 connections to the API. More will be + # opened if they are needed, but they will be closed immediately after + # use. + adapter = requests.adapters.HTTPAdapter(pool_connections=2, + pool_maxsize=2) + self.session = requests.Session() + self.session.mount(self.api_url, adapter) + + self.encoder = encoding.RESTJSONEncoder() + + def _request(self, method, path, data=None, headers={}, **kwargs): + request_url = '{api_url}{path}'.format(api_url=self.api_url, path=path) + + if data is not None: + data = self.encoder.encode(data) + + verify, cert = get_ssl_client_options(self._config) + + headers.update({ + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }) + + return self.session.request(method, + request_url, + headers=headers, + data=data, + verify=verify, + cert=cert, + **kwargs) + + def _get_ironic_api_version_header(self, version=None): + if version is None: + ironic_version = self._get_ironic_api_version() + version = min(ironic_version, AGENT_TOKEN_IRONIC_VERSION) + return {'X-OpenStack-Ironic-API-Version': '%d.%d' % version} + + def _get_ironic_api_version(self): + if self._ironic_api_version: + return self._ironic_api_version + try: + response = self._request('GET', '/') + data = json.loads(response.content) + version = data['default_version']['version'].split('.') + self._ironic_api_version = (int(version[0]), int(version[1])) + return self._ironic_api_version + except Exception: + self._logger.exception("An error occurred while attempting to \ + discover the available Ironic API \ + versions, falling " + "back to using version %s", + ".".join(map(str, MIN_IRONIC_VERSION))) + return MIN_IRONIC_VERSION + + def _error_from_response(self, response): + try: + body = response.json() + except ValueError: + text = response.text + else: + body = body.get('error_message', body) + if not isinstance(body, dict): + # Old ironic format + try: + body = json.loads(body) + except json.decoder.JSONDecodeError: + body = {} + + text = (body.get('faultstring') + or body.get('title') + or response.text) + + return 'Error %d: %s' % (response.status_code, text) + + def lookup_node(self, timeout, starting_interval, + node_uuid=None, max_interval=30): + retry = tenacity.retry( + retry=tenacity.retry_if_result(lambda r: r is False), + stop=tenacity.stop_after_delay(timeout), + wait=tenacity.wait_random_exponential(min=starting_interval, + max=max_interval), + reraise=True) + try: + return retry(self._do_lookup)(node_uuid=node_uuid) + except tenacity.RetryError: + raise error.LookupNodeError('Could not look up node info. Check ' + 'logs for details.') + + def _do_lookup(self, node_uuid): + """The actual call to lookup a node.""" + params = { + 'addresses': self.node.get('nics')[0]['mac'] + } + if node_uuid: + params['node_uuid'] = node_uuid + + self._logger.debug( + 'Looking up node with addresses %r and UUID %s at %s', + params['addresses'], node_uuid, self.api_url) + + try: + response = self._request( + 'GET', self.lookup_api, + headers=self._get_ironic_api_version_header(), + params=params) + except (requests.exceptions.Timeout, + requests.exceptions.ConnectTimeout, + requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, + requests.exceptions.HTTPError) as err: + self._logger.warning( + 'Error detected while attempting to perform lookup ' + 'with %s, retrying. Error: %s', self.api_url, err + ) + return False + except Exception as err: + msg = ('Unhandled error looking up node with addresses {} at ' + '{}: {}'.format(params['addresses'], self.api_url, err)) + self._logger.exception(msg) + return False + + if response.status_code != requests.codes.OK: + self._logger.warning( + 'Failed looking up node with addresses %r at %s. ' + '%s. Check if inspection has completed.', + params['addresses'], self.api_url, + self._error_from_response(response) + ) + return False + + try: + content = json.loads(response.content) + except json.decoder.JSONDecodeError as e: + self._logger.warning('Error decoding response: %s', e) + return False + + # Check for valid response data + if 'node' not in content or 'uuid' not in content['node']: + self._logger.warning( + 'Got invalid node data in response to query for node ' + 'with addresses %r from %s: %s', + params['addresses'], self.api_url, content, + ) + return False + + if 'config' not in content: + # Old API + try: + content['config'] = {'heartbeat_timeout': + content.pop('heartbeat_timeout')} + except KeyError: + self._logger.warning( + 'Got invalid heartbeat from the API: %s', content) + return False + + # Got valid content + return content + + def heartbeat(self, uuid, advertise_address, advertise_protocol='http', + generated_cert=None): + path = self.heartbeat_api.format(uuid=uuid) + + data = {'callback_url': self._get_agent_url(advertise_address, uuid, + advertise_protocol)} + + api_ver = self._get_ironic_api_version() + + if api_ver >= AGENT_TOKEN_IRONIC_VERSION: + data['agent_token'] = self.agent_token + + if api_ver >= AGENT_VERSION_IRONIC_VERSION: + data['agent_version'] = __version__ + + if api_ver >= AGENT_VERIFY_CA_IRONIC_VERSION and generated_cert: + data['agent_verify_ca'] = generated_cert + + api_ver = min(MAX_KNOWN_VERSION, api_ver) + headers = self._get_ironic_api_version_header(api_ver) + + self._logger.debug( + 'Heartbeat: announcing callback URL %s,' + 'API version is %d.%d', + data['callback_url'], *api_ver) + + headers['Connection'] = 'close' + try: + response = self._request('POST', path, data=data, headers=headers) + except requests.exceptions.ConnectionError as e: + raise error.HeartbeatConnectionError(str(e)) + except Exception as e: + raise error.HeartbeatError(str(e)) + + if response.status_code == requests.codes.CONFLICT: + err = self._error_from_response(response) + raise error.HeartbeatConflictError(err) + elif response.status_code == requests.codes.NOT_FOUND: + err = self._error_from_response(response) + raise error.HeartbeatNotFoundError(err) + elif response.status_code != requests.codes.ACCEPTED: + err = self._error_from_response(response) + raise error.HeartbeatError(err) + + def _get_agent_url(self, advertise_address, uuid, + advertise_protocol='http'): + return '{}://{}:{}/{}'.format(advertise_protocol, + advertise_address[0], + advertise_address[1], uuid) + +def get_ssl_client_options(conf): + + if conf.get('FAKE_IPA_INSECURE'): + verify = False + else: + verify = conf.get("FAKE_IPA_CAFILE") or True + if conf.get("FAKE_IPA_CERTFILE") and conf.get("FAKE_IPA_KEYFILE"): + cert = (conf.get("FAKE_IPA_CERTFILE"), conf.get("FAKE_IPA_KEYFILE")) + else: + cert = None + + return verify, cert \ No newline at end of file diff --git a/fake-ipa/fake_ipa/log.py b/fake-ipa/fake_ipa/log.py new file mode 100644 index 0000000..24cb075 --- /dev/null +++ b/fake-ipa/fake_ipa/log.py @@ -0,0 +1,52 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import base64 +import io + +from fake_ipa import base + + +class LogExtension(base.BaseAgentExtension): + + @base.sync_command('collect_system_logs') + def collect_system_logs(self): + """Collect system logs. + + Collect and package diagnostic and support data from the ramdisk. + + :raises: CommandExecutionError if failed to collect the system logs. + :returns: A dictionary with the key `system_logs` and the value + of a gzipped and base64 encoded string of the file with + the logs. + """ + logs = collect_system_logs() + return {'system_logs': logs} + + +def _encode_as_text(s): + if isinstance(s, str): + s = s.encode('utf-8') + s = base64.b64encode(s) + return s.decode('ascii') + + +def collect_system_logs(journald_max_lines=None): + """Collect system logs. + + :param journald_max_lines: Maximum number of lines to retrieve from + the journald. if None, return everything. + :returns: A tar, gzip base64 encoded string with the logs. + """ + + with io.BytesIO() as fp: + return _encode_as_text(fp.getvalue()) diff --git a/fake-ipa/fake_ipa/main.py b/fake-ipa/fake_ipa/main.py new file mode 100644 index 0000000..5c5bd6a --- /dev/null +++ b/fake-ipa/fake_ipa/main.py @@ -0,0 +1,300 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import argparse +import logging +import sys +from threading import Thread + +from flask import Flask +from flask import json +from flask import request +from werkzeug.exceptions import HTTPException +from werkzeug.exceptions import Unauthorized +from werkzeug import Response + + +from fake_ipa import encoding +from fake_ipa.fake_agent import FakeIronicPythonAgent +from fake_ipa.heartbeater import Heatbeater + + +class Application(Flask): + agents = {} + booted_q = set() + + +app = Application(__name__) +app.logger.setLevel(logging.DEBUG) + +@app.errorhandler(HTTPException) +def handle_exception(e): + """Return JSON instead of HTML for HTTP errors.""" + + # start with the correct headers and status code from the error + response = e.get_response() + # replace the body with JSON + response.data = json.dumps({ + "code": e.code, + "name": e.name, + "description": e.description, + }) + response.content_type = "application/json" + return response + + +@app.route('/', methods=['PUT']) +def notification_handler(): + """ + Endpoint to receive notifications about Systems power state updates + + This function receives a JSON representing a system's current state + It boot or destroy FakeIPA for a sytem based on the pending power state + recieved + + Example System JSON: + { + "uuid": "27946b59-9e44-4fa7-8e91-f3527a1ef094", + "name": "fake1", + "power_state": "On", + ... + "boot_device": "Pxe", + "pending_power": { + "power_state": "Off", + "apply_time": 1720787353 + } + } + + Processing Logic: + 1. If the 'pending_power' field is missing or empty, no action is taken. + 2. If 'pending_power.power_state' is 'On': + a. If the system is not already booted, it will be added to the boot queue + and the boot process will start after the delay specified by 'apply_time'. + 3. If 'pending_power.power_state' is not 'On': + a. If the system is currently booted, it will be removed from the boot queue + and shutdown IPA after the delay specified by 'apply_time'. + + Note: + - If the system is already powered on or off, duplicate 'pending_power' state updates + should be ignored. + """ + system = request.json + system_name = system.get('name', 'unknown') + app.logger.info("Received system update for %s: %s", system_name, system) + + # Check if 'pending_power' is present and not None or empty + if 'pending_power' not in system or not system['pending_power']: + # No action + app.logger.info("No pending power state. No action taken for system: %s", system_name) + return '', 204 + + pending_power_state = system['pending_power']['power_state'] + + if pending_power_state == 'On': + app.logger.info("Pending power state is 'On' for system: %s", system_name) + # If the system is not already booted and the boot device is not 'Hdd', initiate boot process + if not is_booted(system) and system['boot_device'] != 'Hdd': + app.logger.info("Boot IPA for System %s.", system_name) + app.booted_q.add(system['uuid']) + boot(system) + else: + app.logger.info("System %s is already booted or boot device is 'Hdd'. No boot action taken.", system_name) + else: + app.logger.info("Pending power state is 'Off' or other state for system: %s", system_name) + # If the system is currently booted, initiate destruction process + if is_booted(system): + app.logger.info("Shutdown IPA for System %s", system_name) + app.booted_q.remove(system['uuid']) + remove_from_heartbeater(system['uuid']) + return '', 204 + +def is_booted(system): + return system['uuid'] in app.booted_q + +def boot(system): + # init agent if not already done + if not hasattr(FakeIronicPythonAgent, 'api'): + FakeIronicPythonAgent.initialize(app.config, app.logger, app) + ipa = FakeIronicPythonAgent(system, app.config.get( + 'FAKE_IPA_API_URL', 'http://localhost:6385')) + thread = Thread(target=ipa.boot, daemon=True) + thread.start() + + +def remove_from_heartbeater(uuid): + # init agent if not already done + if not hasattr(FakeIronicPythonAgent, 'api'): + FakeIronicPythonAgent.initialize(app.config, app.logger, app) + Heatbeater.remove_from_heartbeater_q(uuid) + + +# IPA API +_DOCS_URL = 'https://docs.openstack.org/ironic-python-agent' +_CUSTOM_MEDIA_TYPE = 'application/vnd.openstack.ironic-python-agent.v1+json' + + +def jsonify(value, status=200): + """Convert value to a JSON response using the custom encoder.""" + + encoder = encoding.RESTJSONEncoder() + data = encoder.encode(value) + return Response(data, status=status, mimetype='application/json') + + +def make_link(url, rel_name, resource='', resource_args='', + bookmark=False, type_=None): + if rel_name == 'describedby': + url = _DOCS_URL + type_ = 'text/html' + elif rel_name == 'bookmark': + bookmark = True + + template = ('%(root)s/%(resource)s' if bookmark + else '%(root)s/v1/%(resource)s') + template += ('%(args)s' + if resource_args.startswith('?') or not resource_args + else '/%(args)s') + + result = {'href': template % {'root': url, + 'resource': resource, + 'args': resource_args}, + 'rel': rel_name} + if type_: + result['type'] = type_ + return result + + +def version(url): + return { + 'id': 'v1', + 'links': [ + make_link(url, 'self', 'v1', bookmark=True), + make_link(url, 'describedby', bookmark=True), + ], + } + + +@app.route('//', methods=['GET']) +def api_root(uuid): + url = request.url_root.rstrip('/') + return jsonify({ + 'name': 'OpenStack Ironic Fake Python Agent API', + 'description': ('Ironic Fake Python Agent is a ' + 'fake provisioning agent for ' + 'OpenStack Ironic'), + 'versions': [version(url)], + 'default_version': version(url), + }) + + +@app.route('//v1/', methods=['GET']) +def api_v1(uuid): + url = request.url_root.rstrip('/') + return jsonify(dict({ + 'commands': [ + make_link(url, 'self', 'commands'), + make_link(url, 'bookmark', 'commands'), + ], + 'status': [ + make_link(url, 'self', 'status'), + make_link(url, 'bookmark', 'status'), + ], + 'media_types': [ + {'base': 'application/json', + 'type': _CUSTOM_MEDIA_TYPE}, + ], + }, **version(url))) + + +@app.route('//v1/commands/', methods=['GET']) +def api_list_commands(uuid): + try: + results = app.agents[uuid].list_command_results() + except KeyError: + pass + return jsonify({'commands': results}) + + +@app.route('//v1/commands/', methods=['GET']) +def api_get_command(uuid, cmd): + result = app.agents[uuid].get_command_result(cmd) + wait = request.args.get('wait') + + if wait and wait.lower() == 'true': + result.join() + + return jsonify(result) + + +@app.route('//v1/commands/', methods=['POST']) +def api_run_command(uuid): + body = request.get_json(force=True) + if ('name' not in body or 'params' not in body + or not isinstance(body['params'], dict)): + raise HTTPException.BadRequest('Missing or invalid name or params') + + token = request.args.get('agent_token', None) + if not app.agents[uuid].validate_agent_token(token): + raise Unauthorized('Token invalid.') + # get uuid + result = app.agents[uuid].execute_command( + body['name'], **body['params']) + wait = request.args.get('wait') + if wait and wait.lower() == 'true': + result.join() + return jsonify(result) + + +def parse_args(): + parser = argparse.ArgumentParser('sushy-fake-ipa') + parser.add_argument('--config', + type=str, + help='Config file path. Can also be set via ' + 'environment variable SUSHY_FAKE_IPA_CONFIG.') + parser.add_argument('-i', '--interface', + type=str, + help='IP address of the local interface to listen ' + 'at. Can also be set via config variable ' + 'SUSHY_FAKE_IPA_LISTEN_IP. Default is all ' + 'local interfaces.') + parser.add_argument('-p', '--port', + type=int, + help='TCP port to bind the server to. Can also be ' + 'set via config variable ' + 'SUSHY_FAKE_IPA_LISTEN_PORT. Default is 9999.') + return parser.parse_args() + + +def main(): + args = parse_args() + app.config.from_pyfile(args.config) + DEFAULT_PORT = 9999 + if not app.config.get('FAKE_IPA_ADVERTISE_ADDRESS_IP'): + app.logger.error( + 'Please set FAKE_IPA_ADVERTISE_ADDRESS_IP in config file' + ) + return 1 + if not app.config.get('FAKE_IPA_ADVERTISE_ADDRESS_PORT'): + app.config.setdefault('FAKE_IPA_ADVERTISE_ADDRESS_PORT', DEFAULT_PORT) + + app.logger.info( + 'FAKE_IPA_ADVERTISE_ADDRESS_IP: %s', + app.config.get('FAKE_IPA_ADVERTISE_ADDRESS_IP') + ) + app.run(host=app.config.get('SUSHY_FAKE_IPA_LISTEN_IP', '0.0.0.0'), + port=app.config.get('SUSHY_FAKE_IPA_LISTEN_PORT', DEFAULT_PORT), + debug=True) + + return 0 + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/fake-ipa/fake_ipa/standby.py b/fake-ipa/fake_ipa/standby.py new file mode 100644 index 0000000..1e456af --- /dev/null +++ b/fake-ipa/fake_ipa/standby.py @@ -0,0 +1,52 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +import requests + +from fake_ipa import base + +LOG = logging.getLogger(__name__) + + +class StandbyExtension(base.BaseAgentExtension): + @base.async_command('power_off') + def power_off(self): + """Powers off the agent's system. + + As this is running with fakedriver we need to turn the node off + by calling the redfish system. + """ + LOG.info('Powering off system') + sushytools_url = \ + '{}/redfish/v1/Systems/{}/Actions/ComputerSystem.Reset'.format( + self.agent._config.get('FAKE_IPA_REDFISH_URL'), + self.agent.system['uuid']) + data = { + "Action": "Reset", + "ResetType": "ForceOff" + } + auth = requests.auth.HTTPBasicAuth( + self.agent._config.get('FAKE_IPA_REDFISH_USER', + 'admin'), + self.agent._config.get('FAKE_IPA_REDFISH_PASSWORD', + 'password')) + requests.post(sushytools_url, json=data, verify=False, auth=auth, + headers={'Content-type': 'application/json'}) + + @base.sync_command('get_partition_uuids') + def get_partition_uuids(self): + """Return partition UUIDs.""" + # NOTE(dtantsur): None means prepare_image hasn't been called (an empty + # dict is used for whole disk images). + return {} diff --git a/fake-ipa/requirements.txt b/fake-ipa/requirements.txt new file mode 100644 index 0000000..7c0a351 --- /dev/null +++ b/fake-ipa/requirements.txt @@ -0,0 +1,3 @@ +Flask>=1.0.2 +requests>=2.14.2 +tenacity>=6.2.0 diff --git a/fake-ipa/setup.cfg b/fake-ipa/setup.cfg new file mode 100644 index 0000000..29567fe --- /dev/null +++ b/fake-ipa/setup.cfg @@ -0,0 +1,12 @@ +[metadata] +name = fake-ipa + + + +[files] +packages = + fake_ipa + +[entry_points] +console_scripts = + fake-ipa = fake_ipa.main:main diff --git a/fake-ipa/setup.py b/fake-ipa/setup.py new file mode 100644 index 0000000..cd35c3c --- /dev/null +++ b/fake-ipa/setup.py @@ -0,0 +1,20 @@ +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import setuptools + +setuptools.setup( + setup_requires=['pbr>=2.0.0'], + pbr=True)