From f575187213f0e3133ac3fec74800a5e1a834b912 Mon Sep 17 00:00:00 2001 From: Jan Bundesmann Date: Tue, 21 May 2024 22:46:04 +0200 Subject: [PATCH] Foreman broker (#291) * Add foreman broker * add example settings for Foreman broker * Reorganize classes - Rename ForemanAPI to ForemanBind - Put Foreman Bind in a separate file - Create exception for FormanBind - Make internal method private - Add method to query hostgroup information * Apply organizational changes in tests too * Get rid of constructor call in provider_help * Hotfix for github action --------- Co-authored-by: Jan Bundesmann --- .github/workflows/codeql-analysis.yml | 1 + .gitignore | 1 + broker/binds/foreman.py | 162 ++++++++++++++ broker/exceptions.py | 6 + broker/providers/foreman.py | 294 +++++++++++++++++++++++++ broker_settings.yaml.example | 17 ++ tests/data/foreman/fake_get.json | 23 ++ tests/data/foreman/fake_hosts.json | 196 +++++++++++++++++ tests/data/foreman/fake_jobs.json | 133 +++++++++++ tests/data/foreman/fake_resources.json | 19 ++ tests/providers/test_foreman.py | 141 ++++++++++++ 11 files changed, 993 insertions(+) create mode 100644 broker/binds/foreman.py create mode 100644 broker/providers/foreman.py create mode 100644 tests/data/foreman/fake_get.json create mode 100644 tests/data/foreman/fake_hosts.json create mode 100644 tests/data/foreman/fake_jobs.json create mode 100644 tests/data/foreman/fake_resources.json create mode 100644 tests/providers/test_foreman.py diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 82c7ad45..ecc81e23 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -49,6 +49,7 @@ jobs: env: BROKER_DIRECTORY: "${{ github.workspace }}/broker_dir" run: | + cp broker_settings.yaml.example ${BROKER_DIRECTORY}/broker_settings.yaml pip install uv uv pip install --system "broker[dev,docker] @ ." ls -l "$BROKER_DIRECTORY" diff --git a/.gitignore b/.gitignore index 70ccf710..761d76d3 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,4 @@ ENV/ *settings.yaml inventory.yaml *.bak +/bin/* diff --git a/broker/binds/foreman.py b/broker/binds/foreman.py new file mode 100644 index 00000000..c753f3b2 --- /dev/null +++ b/broker/binds/foreman.py @@ -0,0 +1,162 @@ +"""Foreman provider implementation.""" +import time + +from logzero import logger +import requests + +from broker import exceptions +from broker.settings import settings + + +class ForemanBind: + """Default runtime to query Foreman.""" + + headers = { + "Content-Type": "application/json", + } + + def __init__(self, **kwargs): + self.foreman_username = settings.foreman.foreman_username + self.foreman_password = settings.foreman.foreman_password + self.url = settings.foreman.foreman_url + self.prefix = settings.foreman.name_prefix + self.verify = settings.foreman.verify + self.session = requests.session() + + def _interpret_response(self, response): + """Handle responses from Foreman, in particular catch errors.""" + if "error" in response: + if "Unable to authenticate user" in response["error"]["message"]: + raise exceptions.AuthenticationError(response["error"]["message"]) + raise exceptions.ForemanBindError( + provider=self.__class__.__name__, + message=" ".join(response["error"]["full_messages"]), + ) + if "errors" in response: + raise exceptions.ForemanBindError( + provider=self.__class__.__name__, message=" ".join(response["errors"]["base"]) + ) + return response + + def _get(self, endpoint): + """Send GET request to Foreman API.""" + response = self.session.get( + self.url + endpoint, + auth=(self.foreman_username, self.foreman_password), + headers=self.headers, + verify=self.verify, + ).json() + return self._interpret_response(response) + + def _post(self, endpoint, **kwargs): + """Send POST request to Foreman API.""" + response = self.session.post( + self.url + endpoint, + auth=(self.foreman_username, self.foreman_password), + headers=self.headers, + verify=self.verify, + **kwargs, + ).json() + return self._interpret_response(response) + + def _delete(self, endpoint, **kwargs): + """Send DELETE request to Foreman API.""" + response = self.session.delete( + self.url + endpoint, + auth=(self.foreman_username, self.foreman_password), + headers=self.headers, + verify=self.verify, + **kwargs, + ) + return self._interpret_response(response) + + def obtain_id_from_name(self, resource_type, resource_name): + """Obtain id for resource with given name. + + :param resource_type: Resource type, like hostgroups, hosts, ... + + :param resource_name: String-like identifier of the resource + + :return: ID of the found object + """ + response = self._get( + f"/api/{resource_type}?per_page=200", + ) + try: + result = response["results"] + resource = next( + x + for x in result + if x.get("title") == resource_name or x.get("name") == resource_name + ) + id_ = resource["id"] + except KeyError: + logger.error(f"Could not find {resource_type} {resource_name}") + raise + except StopIteration: + raise exceptions.ForemanBindError( + provider=self.__class__.__name__, + message=f"Could not find {resource_name} in {resource_type}", + ) + return id_ + + def create_job_invocation(self, data): + """Run a job from the provided data.""" + return self._post( + "/api/job_invocations", + json=data, + )["id"] + + def job_output(self, job_id): + """Return output of job.""" + return self._get(f"/api/job_invocations/{job_id}/outputs")["outputs"][0]["output"] + + def wait_for_job_to_finish(self, job_id): + """Poll API for job status until it is finished. + + :param job_id: id of the job to poll + """ + still_running = True + while still_running: + response = self._get(f"/api/job_invocations/{job_id}") + still_running = response["status_label"] == "running" + time.sleep(1) + + def hostgroups(self): + """Return list of available hostgroups.""" + return self._get("/api/hostgroups") + + def hostgroup(self, name): + """Return list of available hostgroups.""" + hostgroup_id = self.obtain_id_from_name("hostgroups", name) + return self._get(f"/api/hostgroups/{hostgroup_id}") + + def hosts(self): + """Return list of hosts deployed using this prefix.""" + return self._get(f"/api/hosts?search={self.prefix}")["results"] + + def image_uuid(self, compute_resource_id, image_name): + """Return the uuid of a VM image on a specific compute resource.""" + try: + return self._get( + "/api/compute_resources/" + f"{compute_resource_id}" + f"/images/?search=name={image_name}" + )["results"][0]["uuid"] + except IndexError: + raise exceptions.ForemanBindError(f"Could not find {image_name} in VM images") + + def create_host(self, data): + """Create a host from the provided data.""" + return self._post("/api/hosts", json=data) + + def wait_for_host_to_install(self, hostname): + """Poll API for host build status until it is built. + + :param hostname: name of the host which is currently being built + """ + building = True + while building: + host_status = self._get(f"/api/hosts/{hostname}") + building = host_status["build_status"] != 0 + time.sleep(1) diff --git a/broker/exceptions.py b/broker/exceptions.py index 3b13afaa..4642d382 100644 --- a/broker/exceptions.py +++ b/broker/exceptions.py @@ -78,3 +78,9 @@ class UserError(BrokerError): """Raised when a user causes an otherwise unclassified error.""" error_code = 13 + + +class ForemanBindError(BrokerError): + """Raised when a problem occurs at the Foreman bind level.""" + + error_code = 14 diff --git a/broker/providers/foreman.py b/broker/providers/foreman.py new file mode 100644 index 00000000..14e572d5 --- /dev/null +++ b/broker/providers/foreman.py @@ -0,0 +1,294 @@ +"""Foreman provider implementation.""" +import inspect +from uuid import uuid4 + +import click +from dynaconf import Validator +from logzero import logger + +from broker.binds import foreman +from broker.helpers import Result +from broker.providers import Provider +from broker.settings import settings + + +class Foreman(Provider): + """Foreman provider class providing an interface around the Foreman API.""" + + _validators = [ + Validator("FOREMAN.organization", must_exist=True), + Validator("FOREMAN.location", must_exist=True), + Validator("FOREMAN.foreman_username", must_exist=True), + Validator("FOREMAN.foreman_password", must_exist=True), + Validator("FOREMAN.foreman_url", must_exist=True), + Validator("FOREMAN.hostgroup", must_exist=False), + Validator("FOREMAN.verify", default=False), + ] + _sensitive_attrs = ["foreman_password"] + + _checkout_options = [ + click.option( + "--hostgroup", + type=str, + help="Name of the Foreman hostgroup to deploy the host", + ), + ] + _execute_options = [] + _extend_options = [] + + hidden = False + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + if kwargs.get("bind") is not None: + self._runtime_cls = kwargs.pop("bind") + else: + self._runtime_cls = foreman.ForemanBind + + self.runtime = self._runtime_cls( + foreman_username=settings.foreman.foreman_username, + foreman_password=settings.foreman.foreman_password, + url=settings.foreman.foreman_url, + prefix=settings.foreman.name_prefix, + verify=settings.foreman.verify, + ) + self.prefix = settings.foreman.name_prefix + + self.organization_id = self.runtime.obtain_id_from_name( + "organizations", settings.foreman.organization + ) + self.location_id = self.runtime.obtain_id_from_name("locations", settings.foreman.location) + + def release(self, host): + """Release a host. + + :param host: Hostname or ID of the host to delete + """ + data = { + "organization_id": self.organization_id, + "location_id": self.location_id, + } + self.runtime._delete( + f"/api/hosts/{host}", + json=data, + ) + + def extend(self): + """There is no need to extend a host on Foreman.""" + pass + + def _host_execute(self, command): + """Execute command on a single host. + + :param command: a command to be executed on the target host + + :return: Result object containing information about the executed job + """ + hostname = self.last_deployed_host + + return self.execute( + hostname=hostname, + job_template="Run Command - Script Default", + command=command, + ) + + def _parse_job_output(self, job_output): + """Parse response Foreman gives when querying job output. + + Example data can be found in tests/data/foreman/fake_jobs.json + + Typically the data is a list. In case of failures the last element + of the output has the element "output_type" "debug" otherwise it is + "stdout". + + The element before or the last element in case of success contains the + statusline with the errorcode. + """ + if job_output[-1]["output_type"] == "debug": + # command was not successful + statusline = job_output[-2]["output"] + status = int(statusline.split(": ")[1]) + stdout = "\n".join([item["output"] for item in job_output[:-2]]) + stderr = "" + else: + # command was successful + status = 0 + stdout = "\n".join([item["output"] for item in job_output[:-1]]) + stderr = "" + return Result(status=status, stdout=stdout, stderr=stderr) + + def execute(self, hostname, job_template, **kwargs): + """Execute remote execution on target host. + + :param hostname: hostname to perform remote execution on + + :param job_template: name of the job template + + :param kwargs: input parameters for the job template + + :return: Result object containing all information about executed jobs + """ + job_template_id = self.runtime.obtain_id_from_name("job_templates", job_template) + data = { + "organization_id": self.organization_id, + "location_id": self.location_id, + "job_invocation": { + "job_template_id": job_template_id, + "targeting_type": "static_query", + "inputs": kwargs, + "search_query": f"name={hostname}", + }, + } + job_id = self.runtime.create_job_invocation(data) + self.runtime.wait_for_job_to_finish(job_id) + job_output = self.runtime.job_output(job_id) + + return self._parse_job_output(job_output) + + def provider_help( + self, + hostgroups=False, + hostgroup=None, + **kwargs, + ): + """Return useful information about Foreman provider.""" + if hostgroups: + all_hostgroups = self.runtime.hostgroups() + logger.info(f"On Foreman {self.instance} you have the following hostgroups:") + for hostgroup in all_hostgroups["results"]: + logger.info(f"- {hostgroup['title']}") + elif hostgroup: + logger.info( + f"On Foreman {self.instance} the hostgroup {hostgroup} has the following properties:" + ) + data = self.runtime.hostgroup(name=hostgroup) + fields_of_interest = { + "description": "description", + "operating_system": "operatingsystem_name", + "domain": "domain_name", + "subnet": "subnet_name", + "subnet6": "subnet6_name", + } + for name, field in fields_of_interest.items(): + value = data.get(field, False) + if value: + logger.info(f" {name}: {value}") + + def _compile_host_info(self, host): + return { + "name": host["certname"], # alternatives: name, display_name + "hostgroup": host["hostgroup_title"], + "hostname": host["certname"], # alternatives: name, display_name + "ip": host["ip"], + "_broker_provider": "Foreman", + "_broker_provider_instance": self.instance, + } + + def get_inventory(self, *args, **kwargs): + """Synchronize list of hosts on Foreman using set prefix.""" + all_hosts = self.runtime.hosts() + with click.progressbar(all_hosts, label="Compiling host information") as hosts_bar: + compiled_host_info = [self._compile_host_info(host) for host in hosts_bar] + return compiled_host_info + + def _host_release(self): + """Delete a specific hostDelete a specific host.""" + caller_host = inspect.stack()[1][0].f_locals["host"].hostname + self.release(caller_host) + + def _set_attributes(self, host_inst, broker_args=None, misc_attrs=None): + """Extend host object by required parameters and methods.""" + host_inst.__dict__.update( + { + "_prov_inst": self, + "_broker_provider": "Foreman", + "_broker_args": broker_args, + "release": self._host_release, + "execute": self._host_execute, + } + ) + + def construct_host(self, provider_params, host_classes, **kwargs): + """Construct a broker host from a Foreman host. + + :param provider_params: a container instance object + + :param host_classes: host object + + :return: broker object of constructed host instance + """ + logger.debug(f"constructing with {provider_params=}\n{host_classes=}\n{kwargs=}") + if not provider_params: + host_inst = host_classes[kwargs.get("type", "host")](**kwargs) + self._set_attributes(host_inst, broker_args=kwargs) + return host_inst + name = provider_params["name"] + host_inst = host_classes["host"]( + **{ + **kwargs, + "hostname": name, + "name": name, + } + ) + self._set_attributes(host_inst, broker_args=kwargs) + return host_inst + + def _gen_name(self): + return f"{self.prefix}-{str(uuid4()).split('-')[0]}" + + @Provider.register_action("hostgroup") + def create_host(self, hostgroup, **host): + """Create a new Foreman host. + + :param host: additional parameters for host creation + + :return: Foreman's response from host creation + """ + if not host.get("name"): + host["name"] = self._gen_name() + + logger.debug(f"Creating host {host['name']} from hostgroup '{hostgroup}'") + + host["hostgroup_id"] = self.runtime.obtain_id_from_name("hostgroups", hostgroup) + host["build"] = True + host["compute_attributes"] = {"start": "1"} + host["organization_id"] = self.organization_id + host["location_id"] = self.location_id + + image_name = host.pop("image", False) + compute_resource_name = host.pop("computeresource", False) + if image_name and compute_resource_name: + host["compute_resource_id"] = self.runtime.obtain_id_from_name( + "compute_resources", compute_resource_name + ) + host["compute_attributes"]["image_id"] = self.runtime.image_uuid( + host["compute_resource_id"], image_name + ) + host["provision_method"] = "image" + + logger.debug( + "Setting parameters for image based deployment: {\n" + f" compute_resource_id: {host['compute_resource_id']},\n" + f" image_id: {host['compute_attributes']['image_id']},\n" + f" provision_method: {host['provision_method']}\n" + "}" + ) + + data = { + "organization_id": self.organization_id, + "location_id": self.location_id, + "host": host, + } + result = self.runtime.create_host(data) + + self.runtime.wait_for_host_to_install(result["name"]) + self.last_deployed_host = result["name"] + + # set hostname = hostname -f + self.execute( + hostname=result["name"], + job_template="Run Command - Script Default", + command=f"hostname {result['name']}", + ) + return result diff --git a/broker_settings.yaml.example b/broker_settings.yaml.example index 6c41583b..bf5e4bcb 100644 --- a/broker_settings.yaml.example +++ b/broker_settings.yaml.example @@ -46,6 +46,23 @@ Container: # name_prefix: test results_limit: 50 auto_map_ports: False +Foreman: + instances: + - foreman1: + foreman_url: https://test.fore.man + foreman_username: admin + foreman_password: secret + organization: ORG + location: LOC + verify: ./ca.crt + default: true + - foreman2: + foreman_url: https://other-test.fore.man + foreman_username: admin + foreman_password: secret + organization: ORG + location: LOC + name_prefix: broker Beaker: hub_url: max_job_wait: 24h diff --git a/tests/data/foreman/fake_get.json b/tests/data/foreman/fake_get.json new file mode 100644 index 00000000..be3d1e6f --- /dev/null +++ b/tests/data/foreman/fake_get.json @@ -0,0 +1,23 @@ +{ + "/api/hosts?search=broker": { + "results": [ + { + "certname": "host1.fq.dn", + "hostgroup_title": "hg1", + "ip": "1.2.3.4" + }, + { + "certname": "host2.fq.dn", + "hostgroup_title": "hg2", + "ip": "1.2.3.5" + } + ] + }, + "/api/compute_resources/42/images/?search=name=debian12": { + "results": [ + { + "uuid": "5023c760-9661-190c-2300-ef78fcf44580" + } + ] + } +} diff --git a/tests/data/foreman/fake_hosts.json b/tests/data/foreman/fake_hosts.json new file mode 100644 index 00000000..1a93a8ec --- /dev/null +++ b/tests/data/foreman/fake_hosts.json @@ -0,0 +1,196 @@ +{ + "ip": "10.0.0.2", + "ip6": null, + "last_report": null, + "mac": "00:11:22:33:44:55", + "realm_id": null, + "realm_name": null, + "sp_mac": null, + "sp_ip": null, + "sp_name": null, + "domain_id": 2, + "domain_name": "local", + "architecture_id": 1, + "architecture_name": "x86_64", + "operatingsystem_id": 2, + "operatingsystem_name": "Debian 12 Bookworm", + "subnet_id": 1, + "subnet_name": "10.0.0.0", + "subnet6_id": null, + "subnet6_name": null, + "sp_subnet_id": null, + "ptable_id": 141, + "ptable_name": "Preseed default", + "medium_id": 36, + "medium_name": "Debian Mirror", + "pxe_loader": "PXELinux BIOS", + "build": true, + "comment": null, + "disk": null, + "initiated_at": null, + "installed_at": null, + "model_id": null, + "hostgroup_id": 7, + "owner_id": 4, + "owner_name": "Admin User", + "owner_type": "User", + "creator_id": 4, + "creator": "Admin User", + "enabled": true, + "managed": true, + "use_image": null, + "image_file": "", + "uuid": "5023c760-9661-190c-2300-ef78fcf44580", + "compute_resource_id": 1, + "compute_resource_name": "my.v.sphere", + "compute_profile_id": 4, + "compute_profile_name": "default", + "capabilities": [ + "build", + "image" + ], + "provision_method": "build", + "certname": "broker.local", + "image_id": null, + "image_name": null, + "created_at": "2024-05-02 15:22:30 UTC", + "updated_at": "2024-05-02 15:22:30 UTC", + "last_compile": null, + "global_status": 0, + "global_status_label": "OK", + "bmc_available": false, + "organization_id": 1, + "organization_name": "ORG", + "location_id": 2, + "location_name": "LOC", + "puppet_status": 0, + "model_name": null, + "build_status": 1, + "build_status_label": "Pending installation", + "name": "broker.local", + "id": 71, + "display_name": "broker.local", + "puppet_proxy_id": null, + "puppet_proxy_name": null, + "puppet_ca_proxy_id": null, + "puppet_ca_proxy_name": null, + "puppet_proxy": null, + "puppet_ca_proxy": null, + "cockpit_url": null, + "compute_resource_provider": "vmware", + "token": "3f0938aa-fd68-4176-81e9-c66f85338176", + "hostgroup_name": "Debian 12 Bookworm", + "hostgroup_title": "Debian 12 Bookworm", + "parameters": [], + "all_parameters": [ + { + "priority": 0, + "created_at": "2024-04-12 07:42:11 UTC", + "updated_at": "2024-04-12 07:42:11 UTC", + "id": 8, + "name": "time_zone", + "parameter_type": "string", + "associated_type": "global", + "hidden_value?": false, + "value": "Europe/Berlin" + }, + { + "priority": 0, + "created_at": "2024-04-12 07:19:11 UTC", + "updated_at": "2024-04-12 07:19:11 UTC", + "id": 2, + "name": "host_registration_remote_execution", + "parameter_type": "boolean", + "associated_type": "global", + "hidden_value?": false, + "value": true + }, + { + "priority": 0, + "created_at": "2024-04-12 07:19:11 UTC", + "updated_at": "2024-04-12 07:19:11 UTC", + "id": 1, + "name": "host_registration_insights", + "parameter_type": "boolean", + "associated_type": "global", + "hidden_value?": false, + "value": false + }, + { + "priority": 0, + "created_at": "2024-04-12 07:19:11 UTC", + "updated_at": "2024-04-12 07:19:11 UTC", + "id": 3, + "name": "host_packages", + "parameter_type": "string", + "associated_type": "global", + "hidden_value?": false, + "value": "" + }, + { + "priority": 0, + "created_at": "2024-04-12 07:33:42 UTC", + "updated_at": "2024-04-12 07:33:42 UTC", + "id": 4, + "name": "blacklist_kernel_modules", + "parameter_type": "string", + "associated_type": "global", + "hidden_value?": false, + "value": "pcspkr floppy" + }, + { + "priority": 0, + "created_at": "2024-04-12 07:39:26 UTC", + "updated_at": "2024-04-12 07:39:26 UTC", + "id": 6, + "name": "ansible_roles_check_mode", + "parameter_type": "boolean", + "associated_type": "global", + "hidden_value?": false, + "value": false + } + ], + "host_collections": [], + "operatingsystem_family": "Debian", + "operatingsystem_major": "12", + "interfaces": [ + { + "subnet_id": 1, + "subnet_name": "10.0.0.0", + "subnet6_id": null, + "subnet6_name": null, + "domain_id": 2, + "domain_name": "local", + "created_at": "2024-05-02 15:22:30 UTC", + "updated_at": "2024-05-02 15:22:30 UTC", + "managed": true, + "identifier": null, + "id": 72, + "name": "broker.local", + "ip": "10.0.5.61", + "ip6": null, + "mac": "00:11:22:33:44:55", + "mtu": 1500, + "fqdn": "broker.local", + "primary": true, + "provision": true, + "type": "interface", + "execution": true, + "virtual": false + } + ], + "permissions": { + "cockpit_hosts": true, + "view_hosts": true, + "create_hosts": true, + "edit_hosts": true, + "destroy_hosts": true, + "build_hosts": true, + "power_hosts": true, + "console_hosts": true, + "ipmi_boot_hosts": true, + "forget_status_hosts": true, + "play_roles_on_host": true, + "saltrun_hosts": true + } +} diff --git a/tests/data/foreman/fake_jobs.json b/tests/data/foreman/fake_jobs.json new file mode 100644 index 00000000..bfe2a544 --- /dev/null +++ b/tests/data/foreman/fake_jobs.json @@ -0,0 +1,133 @@ +{ + "success": { + "id": 42, + "result": [ + { + "id": 1, + "template_invocation_id": 2, + "timestamp": 42.42, + "meta": null, + "sequence_id": 0, + "output_type": "stdout", + "output": "fake-host\\n" + }, + { + "output_type": "stdout", + "output": "Exit status: 0", + "timestamp": 42.42 + } + ] + }, + "simple": { + "id": 43, + "result": [ + { + "id": 267, + "template_invocation_id": 218, + "timestamp": 42.42, + "meta": null, + "sequence_id": 0, + "output_type": "stdout", + "output": "cat: /ets/hostname: No such file or directory\\n" + }, + { + "output_type": "stdout", + "output": "Exit status: 1", + "timestamp": 42.42 + }, + { + "id": 269, + "template_invocation_id": 218, + "timestamp": 42.42, + "meta": null, + "sequence_id": 2, + "output_type": "debug", + "output": "StandardError: Job execution failed" + } + ] + }, + "complex": { + "id": 44, + "result": [ + { + "id": 270, + "template_invocation_id": 220, + "timestamp": 42.42, + "meta": null, + "sequence_id": 0, + "output_type": "stdout", + "output": "Hit:1 http://ftp.debian.org:80/debian bookworm InRelease\nHit:2 http://ftp.debian.org:80/debian bookworm-updates InRelease\n" + }, + { + "id": 271, + "template_invocation_id": 220, + "timestamp": 42.42, + "meta": null, + "sequence_id": 1, + "output_type": "stdout", + "output": "Hit:3 http://security.debian.org/debian-security bookworm-security InRelease\nReading package lists..." + }, + { + "id": 272, + "template_invocation_id": 220, + "timestamp": 42.42, + "meta": null, + "sequence_id": 2, + "output_type": "stdout", + "output": "\n" + }, + { + "output_type": "stdout", + "output": "Exit status: 0", + "timestamp": 42.42 + } + ] + }, + "hostname": { + "id": 45, + "result": [ + { + "output_type": "stdout", + "output": "Exit status: 0", + "timestamp": 42.42 + } + ] + }, + "complex-fail": { + "id": 46, + "result": [ + { + "id": 335, + "template_invocation_id": 276, + "timestamp": 1714721372.0171509, + "meta": null, + "sequence_id": 0, + "output_type": "stdout", + "output": "Reading package lists..." + }, + { + "id": 336, + "template_invocation_id": 276, + "timestamp": 1714721373.0201159, + "meta": null, + "sequence_id": 1, + "output_type": "stdout", + "output": "\nBuilding dependency tree...\nReading state information...\nE: Unable to locate package pizzaofen\n" + }, + { + "output_type": "stdout", + "output": "Exit status: 100", + "timestamp": 1714721373.0201159 + }, + { + "id": 338, + "template_invocation_id": 276, + "timestamp": 1714721373.143476, + "meta": null, + "sequence_id": 3, + "output_type": "debug", + "output": "StandardError: Job execution failed" + } + ] + } +} diff --git a/tests/data/foreman/fake_resources.json b/tests/data/foreman/fake_resources.json new file mode 100644 index 00000000..b689e365 --- /dev/null +++ b/tests/data/foreman/fake_resources.json @@ -0,0 +1,19 @@ +{ + "organizations": { + "ORG": 42 + }, + "locations": { + "LOC": 42 + }, + "hostgroups": { + "hg1": 41, + "hg2": 42, + "hg3": 43 + }, + "job_templates": { + "Run Command - Script Default": 42 + }, + "compute_resources": { + "vcenter.fq.dn": 42 + } +} diff --git a/tests/providers/test_foreman.py b/tests/providers/test_foreman.py new file mode 100644 index 00000000..839210b4 --- /dev/null +++ b/tests/providers/test_foreman.py @@ -0,0 +1,141 @@ +import json +import pytest + +from broker import Broker +from broker.helpers import MockStub +from broker.providers.foreman import Foreman +from broker.binds.foreman import ForemanBind +from broker.exceptions import ProviderError + +HOSTGROUP_VALID = "hg1" +HOSTGROUP_INVALID = "hg7" + + +class ForemanApiStub(MockStub, ForemanBind): + """Runtime to mock queries to Foreman.""" + def __init__(self, **kwargs): + MockStub.__init__(self, in_dict={}) + ForemanBind.__init__(self) + + def _post(self, url, **kwargs): + if "/api/job_invocations" in url: + command_slug = kwargs['json']['job_invocation']['inputs']['command'].split(" ")[0] + with open("tests/data/foreman/fake_jobs.json") as jobs_file: + job_data = json.load(jobs_file) + return job_data[command_slug] + if "/api/hosts" in url: + with open("tests/data/foreman/fake_hosts.json") as hosts_file: + host_data = json.load(hosts_file) + return host_data + print(url) + + def _get(self, url): + with open("tests/data/foreman/fake_get.json") as file_: + data = json.load(file_) + try: + return data[url] + except: + raise ProviderError( + provider=self.__class__.__name__, + message=f"Could not find endpoint {url}", + ) + + def _delete(self, url, **kwargs): + print(url) + + def obtain_id_from_name(self, resource_type, resource_name): + with open("tests/data/foreman/fake_resources.json") as resources_file: + resources_data = json.load(resources_file) + try: + return resources_data[resource_type][resource_name] + except: + raise ProviderError( + provider=self.__class__.__name__, + message=f"Could not find {resource_name} in {resource_type}", + ) + + def job_output(self, job_id): + jobs = { + 42: "success", + 43: "simple", + 44: "complex", + 45: "hostname", + 46: "complex-fail", + } + + with open("tests/data/foreman/fake_jobs.json") as jobs_file: + job_data = json.load(jobs_file) + job_key = jobs[job_id] + + return job_data[job_key]["result"] + + def wait_for_job_to_finish(self, job_id): + return + + def wait_for_host_to_install(self, hostname): + return + + +@pytest.fixture +def api_stub(): + return ForemanApiStub() + + +@pytest.fixture +def foreman_stub(api_stub): + return Foreman(bind=ForemanApiStub) + + +def test_empty_init(): + assert Foreman(bind=ForemanApiStub) + + +def test_inventory(foreman_stub): + inventory = foreman_stub.get_inventory() + assert len(inventory) == 2 + assert inventory[1]["name"] == "host2.fq.dn" + assert inventory[0]["ip"] == "1.2.3.4" + + +def test_positive_host_creation(foreman_stub): + new_host = foreman_stub.create_host(hostgroup=HOSTGROUP_VALID) + assert new_host["name"] == "broker.local" + assert new_host["mac"] == "00:11:22:33:44:55" + + +def test_negative_host_creation(foreman_stub): + with pytest.raises(ProviderError): + foreman_stub.create_host(hostgroup=HOSTGROUP_INVALID) + + +def test_positive_host(foreman_stub): + bx = Broker() + new_host = foreman_stub.create_host(hostgroup=HOSTGROUP_VALID) + host = foreman_stub.construct_host(new_host, bx.host_classes) + + assert isinstance(host, bx.host_classes["host"]) + assert host.hostname == "broker.local" + + +def test_positive_remote_execution(foreman_stub): + bx = Broker() + new_host = foreman_stub.create_host(hostgroup=HOSTGROUP_VALID) + host = foreman_stub.construct_host(new_host, bx.host_classes) + + result = host.execute("success") + complex_result = host.execute("complex") + + assert result.status == 0 + assert complex_result.status == 0 + + +def test_negative_remote_execution(foreman_stub): + bx = Broker() + new_host = foreman_stub.create_host(hostgroup=HOSTGROUP_VALID) + host = foreman_stub.construct_host(new_host, bx.host_classes) + + simple_result = host.execute("simple") + complex_result = host.execute("complex-fail") + + assert simple_result.status == 1 + assert complex_result.status == 100