Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: reduce duplicate code in run actions #603

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions ansible_rulebook/action/run_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Copyright 2023 Red Hat, Inc.
#
# 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 asyncio
import logging
import uuid
from typing import Callable
from urllib.parse import urljoin

import drools
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jshimkus-rh Do we need to import specific things here maybe?

from drools import ruleset

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jshimkus-rh The earlier implementation tried using a hierarchical model which @benthomasson didn't want to use and wanted to use composition instead of inheritance, this PR seems to be going using inheritance. So we would need approval from @benthomasson if we wants to use inheritance here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming such an objection from @benthomasson I'd like to hear what he considers to be compositional as there's nothing in the previous code (being changed with this PR) that adheres to the conventional definition of composition; there's no facility being re-used across the various classes, rather there's largely duplicated code implemented separately.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jshimkus-rh Do we need to import specific things here maybe?


from drools import ruleset

We could do that. I chose not to in order to limit possible confusion between drools and EDA's concept of rulesets.

It was motivated by consistency with eliminating the transitive overriding in tests of the drools apis which would have to be specified against run_base.py file rather than the run_job_template file, as an example.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My two cents: From what I understand, composition, whether in contrast to inheritance or not, is a pattern used to decouple pieces of logic or distinct responsibilities. In this scenario, where we have shared logic across similar actions, inheritance seems like a fitting approach to me.


from ansible_rulebook.conf import settings
from ansible_rulebook.exception import ControllerApiException
from ansible_rulebook.job_template_runner import job_template_runner
from ansible_rulebook.util import run_at

from .control import Control
from .helper import Helper
from .metadata import Metadata

logger = logging.getLogger(__name__)


class RunBase:
"""Common superclass for "run" actions."""

@property
def _job_data(self) -> dict:
data = {
"run_at": run_at(),
"matching_events": self.helper.get_events(),
"action": self.helper.action,
"name": self.name,
"job_id": self.job_id,
"ansible_rulebook_id": settings.identifier,
}
return data

@property
def _action_name(self) -> str:
raise NotImplementedError

def __init__(self, metadata: Metadata, control: Control, **action_args):
self.helper = Helper(metadata, control, self._action_name)
self.action_args = action_args
self.job_id = str(uuid.uuid4())
self.name = self.action_args["name"]

async def __call__(self):
await self._pre_process()
await self._job_start_event()
await self._run()
await self._post_process()

async def _do_run(self) -> bool:
raise NotImplementedError

async def _run(self):
retries = self.action_args.get("retries", 0)
if self.action_args.get("retry", False):
retries = max(self.action_args.get("retries", 0), 1)
delay = self.action_args.get("delay", 0)

for i in range(retries + 1):
if i > 0:
if delay > 0:
await asyncio.sleep(delay)
logger.info(
"Previous %s failed. Retry %d of %d",
self._action_name,
i,
retries,
)

retry = await self._do_run()
if not retry:
break

async def _pre_process(self) -> None:
pass

async def _post_process(self) -> None:
pass

async def _job_start_event(self):
await self.helper.send_status(
self._job_data,
obj_type="Job",
)


class RunTemplate(RunBase):
"""Superclass for template-based run actions. Launches the appropriate
specified template on the controller. It waits for the job to be complete.
"""

@property
def _exceptions(self) -> tuple:
return (ControllerApiException,)

@property
def _job_data(self) -> dict:
data = super()._job_data
data["hosts"] = ",".join(self.helper.control.hosts)
return data

@property
def _run_job(self) -> Callable:
raise NotImplementedError

@property
def _template_name(self) -> str:
raise NotImplementedError

@property
def _url_path(self) -> str:
return self._url_prefix + f"{self.controller_job['id']}/" + "details"

@property
def _url_prefix(self) -> str:
return "/#/"

def _make_log(self) -> dict:
log = {
"organization": self.organization,
"job_id": self.job_id,
"status": self.controller_job["status"],
"run_at": self.controller_job["created"],
"url": self._controller_job_url(),
"matching_events": self.helper.get_events(),
}
if "error" in self.controller_job:
log["message"] = self.controller_job["error"]
log["reason"] = {"error": self.controller_job["error"]}
return log

def __init__(self, metadata: Metadata, control: Control, **action_args):
super().__init__(metadata, control, **action_args)
self.organization = self.action_args["organization"]
self.job_args = self.action_args.get("job_args", {})
self.job_args["limit"] = ",".join(self.helper.control.hosts)
self.controller_job = {}

async def __call__(self):
logger.info(
"running %s: %s, organization: %s",
self._template_name,
self.name,
self.organization,
)
logger.info(
"ruleset: %s, rule %s",
self.helper.metadata.rule_set,
self.helper.metadata.rule,
)

self.job_args["extra_vars"] = self.helper.collect_extra_vars(
self.job_args.get("extra_vars", {})
)
await super().__call__()

async def _do_run(self) -> bool:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't actually need a boolean as result here and I think it is not a good pattern. Error handling should be done through exceptions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a binary situation; we have success, retriable errors and non-retriable errors. I find I can live with the idea of using StopAsyncIteration and have pushed a change with that. If people would rather have a specific exception for the hierarchy let me know.

exception = False
try:
controller_job = await self._run_job(
self.name,
self.organization,
self.job_args,
)
except self._exceptions as ex:
exception = True
logger.error(ex)
controller_job = {}
controller_job["status"] = "failed"
controller_job["created"] = run_at()
controller_job["error"] = str(ex)

self.controller_job = controller_job

return (not exception) and (self.controller_job["status"] == "failed")

async def _post_process(self) -> None:
a_log = self._make_log()

await self.helper.send_status(a_log)
set_facts = self.action_args.get("set_facts", False)
post_events = self.action_args.get("post_events", False)

if set_facts or post_events:
ruleset = self.action_args.get(
"ruleset", self.helper.metadata.rule_set
)
logger.debug("set_facts")
facts = self.controller_job.get("artifacts", {})
if facts:
facts = self.helper.embellish_internal_event(facts)
logger.debug("facts %s", facts)
if set_facts:
drools.ruleset.assert_fact(ruleset, facts)
if post_events:
drools.ruleset.post(ruleset, facts)
else:
logger.debug("Empty facts are not set")

await super()._post_process()

def _controller_job_url(self) -> str:
if "id" in self.controller_job:
return urljoin(
job_template_runner.host,
self._url_path,
)
return ""
148 changes: 22 additions & 126 deletions ansible_rulebook/action/run_job_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,146 +12,42 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import asyncio
import logging
import uuid
from urllib.parse import urljoin

from drools import ruleset as lang

from ansible_rulebook.conf import settings
from ansible_rulebook.exception import (
ControllerApiException,
JobTemplateNotFoundException,
)
from ansible_rulebook.exception import JobTemplateNotFoundException
from ansible_rulebook.job_template_runner import job_template_runner
from ansible_rulebook.util import run_at

from .control import Control
from .helper import Helper
from .metadata import Metadata
from .run_base import RunTemplate

logger = logging.getLogger(__name__)


class RunJobTemplate:
class RunJobTemplate(RunTemplate):
"""run_job_template action launches a specified job template on
the controller. It waits for the job to be complete.
"""

def __init__(self, metadata: Metadata, control: Control, **action_args):
self.helper = Helper(metadata, control, "run_job_template")
self.action_args = action_args
self.name = self.action_args["name"]
self.organization = self.action_args["organization"]
self.job_id = str(uuid.uuid4())
self.job_args = self.action_args.get("job_args", {})
self.job_args["limit"] = ",".join(self.helper.control.hosts)
self.controller_job = {}

async def __call__(self):
logger.info(
"running job template: %s, organization: %s",
self.name,
self.organization,
)
logger.info(
"ruleset: %s, rule %s",
self.helper.metadata.rule_set,
self.helper.metadata.rule,
)

self.job_args["extra_vars"] = self.helper.collect_extra_vars(
self.job_args.get("extra_vars", {})
)
await self._job_start_event()
await self._run()

async def _run(self):
retries = self.action_args.get("retries", 0)
if self.action_args.get("retry", False):
retries = max(self.action_args.get("retries", 0), 1)
delay = self.action_args.get("delay", 0)

try:
for i in range(retries + 1):
if i > 0:
if delay > 0:
await asyncio.sleep(delay)
logger.info(
"Previous run_job_template failed. Retry %d of %d",
i,
retries,
)
controller_job = await job_template_runner.run_job_template(
self.name,
self.organization,
self.job_args,
)
if controller_job["status"] != "failed":
break
except (ControllerApiException, JobTemplateNotFoundException) as ex:
logger.error(ex)
controller_job = {}
controller_job["status"] = "failed"
controller_job["created"] = run_at()
controller_job["error"] = str(ex)

self.controller_job = controller_job
await self._post_process()
@property
def _action_name(self):
return "run_job_template"

async def _post_process(self) -> None:
a_log = {
"job_template_name": self.name,
"organization": self.organization,
"job_id": self.job_id,
"status": self.controller_job["status"],
"run_at": self.controller_job["created"],
"url": self._controller_job_url(),
"matching_events": self.helper.get_events(),
}
if "error" in self.controller_job:
a_log["message"] = self.controller_job["error"]
a_log["reason"] = {"error": self.controller_job["error"]}
@property
def _exceptions(self):
return super()._exceptions + (JobTemplateNotFoundException,)

await self.helper.send_status(a_log)
set_facts = self.action_args.get("set_facts", False)
post_events = self.action_args.get("post_events", False)
@property
def _run_job(self):
return job_template_runner.run_job_template

if set_facts or post_events:
ruleset = self.action_args.get(
"ruleset", self.helper.metadata.rule_set
)
logger.debug("set_facts")
facts = self.controller_job.get("artifacts", {})
if facts:
facts = self.helper.embellish_internal_event(facts)
logger.debug("facts %s", facts)
if set_facts:
lang.assert_fact(ruleset, facts)
if post_events:
lang.post(ruleset, facts)
else:
logger.debug("Empty facts are not set")
@property
def _template_name(self):
return "job template"

async def _job_start_event(self):
await self.helper.send_status(
{
"run_at": run_at(),
"matching_events": self.helper.get_events(),
"action": self.helper.action,
"hosts": ",".join(self.helper.control.hosts),
"name": self.name,
"job_id": self.job_id,
"ansible_rulebook_id": settings.identifier,
},
obj_type="Job",
)
@property
def _url_prefix(self):
return super()._url_prefix + "jobs/"

def _controller_job_url(self) -> str:
if "id" in self.controller_job:
return urljoin(
job_template_runner.host,
"/#/jobs/" f"{self.controller_job['id']}/" "details",
)
return ""
def _make_log(self):
log = super()._make_log()
log["job_template_name"] = self.name
return log
Loading