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

Feature/v2 #16

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ demo-managed-system/node_modules
demo-managed-system/package-lock.json
venv
__pycache__
*.joblib
experiments/
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
# UPISAS
Unified Python interface for self-adaptive system exemplars.

Documentation on the experiment runner can be found in [./runner/README.md](./runner/README.md)

### Prerequisites
Tested with Python 3.9.12

### Installation

### Installation - Poetry

Ensure you have Poetry for python installed. See [https://python-poetry.org/docs/#installation](https://python-poetry.org/docs/#installing-with-the-official-installer) for instructions. Poetry manages the creation and activation of a virtual environment for the project, alongside creating a lockfile for the project's dependencies.

In a terminal, navigate to the parent folder of the project and issue:
```
poetry install
```

### Installation - Pip

In a terminal, navigate to the parent folder of the project and issue:
```
virtualenv venv
source venv/bin/activate
pip install -r requirements.txt
```

### Run unit tests
In a terminal, navigate to the parent folder of the project and issue:
```
Expand Down
File renamed without changes.
8 changes: 8 additions & 0 deletions UPISAS/exceptions/demo_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class FishFoundException(Exception):
pass

class FishNotFoundException(Exception):
pass

class LowBatteryException(Exception):
pass
125 changes: 121 additions & 4 deletions UPISAS/exemplar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@
from rich.progress import Progress
from UPISAS import show_progress
import logging
from docker.errors import DockerException
from UPISAS.exceptions import DockerImageNotFoundOnDockerHub
from docker.errors import DockerException, APIError
from UPISAS.exceptions import DockerImageNotFoundOnDockerHub, EndpointNotReachable
from UPISAS.knowledge import Knowledge

import requests

from UPISAS import validate_schema, get_response_for_get_request
import pprint
pp = pprint.PrettyPrinter(indent=4)

logging.getLogger().setLevel(logging.INFO)

Expand All @@ -21,7 +28,9 @@ def __init__(self, base_endpoint: "string with the URL of the exemplar's HTTP se
'''Create an instance of the Exemplar class'''
self.base_endpoint = base_endpoint
image_name = docker_kwargs["image"]
self._container_name = docker_kwargs["name"]
image_owner = image_name.split("/")[0]
self.knowledge = Knowledge(dict(), dict(), list(), dict(), dict(), dict(), dict(), dict())
try:
docker_client = docker.from_env()
try:
Expand All @@ -38,8 +47,13 @@ def __init__(self, base_endpoint: "string with the URL of the exemplar's HTTP se
else:
logging.error(f"image '{image_name}' not found on DockerHub, exiting!")
raise DockerImageNotFoundOnDockerHub
docker_kwargs["detach"] = True
self.exemplar_container = docker_client.containers.create(**docker_kwargs)
try:
self.exemplar_container = docker_client.containers.get(self._container_name)
logging.info(f"container '{self._container_name}' found locally")
except docker.errors.NotFound:
logging.info(f"container '{self._container_name}' not found locally")
docker_kwargs["detach"] = True
self.exemplar_container = docker_client.containers.create(**docker_kwargs)
except DockerException as e:
# TODO: Properly catch various errors. Currently, a lot of errors might be caught here.
# Please check the logs if that happens.
Expand All @@ -51,6 +65,109 @@ def __init__(self, base_endpoint: "string with the URL of the exemplar's HTTP se
def start_run(self):
pass

def _append_data(self, fresh_data):
# recurse on list data
if isinstance(fresh_data, list):
for item in fresh_data:
self._append_data(item)
# append data instance to monitored data
else:
data = self.knowledge.monitored_data
for key in list(fresh_data.keys()):
if key not in data:
data[key] = []
data[key].append(fresh_data[key])
print("[Knowledge]\tdata monitored so far: " + str(self.knowledge.monitored_data))
return True

# Monitor lives in the Exemplar class because it is the Exemplar that is responsible for
# interfacing with the monitored system.
def monitor(self, endpoint_suffix="monitor", with_validation=True):
fresh_data = self._perform_get_request(endpoint_suffix)
print("[Monitor]\tgot fresh_data: " + str(fresh_data))
if with_validation:
validate_schema(fresh_data, self.knowledge.monitor_schema)
self._append_data(fresh_data)
return True

# Similarly, execute also interfaces with the monitored system.
def execute(self, adaptation, endpoint_suffix="execute", with_validation=True):
if with_validation:
validate_schema(adaptation, self.knowledge.execute_schema)
url = '/'.join([self.base_endpoint, endpoint_suffix])
response = requests.put(url, json=adaptation)
print("[Execute]\tposted configuration: " + str(adaptation))
if response.status_code == 404:
logging.error("Cannot execute adaptation on remote system, check that the execute endpoint exists.")
raise EndpointNotReachable
return True

def get_adaptation_options(self, endpoint_suffix: "API Endpoint" = "adaptation_options", with_validation=True):
self.knowledge.adaptation_options = self._perform_get_request(endpoint_suffix)
if with_validation:
validate_schema(self.knowledge.adaptation_options, self.knowledge.adaptation_options_schema)
logging.info("adaptation_options set to: ")
pp.pprint(self.knowledge.adaptation_options)

def get_monitor_schema(self, endpoint_suffix = "monitor_schema"):
self.knowledge.monitor_schema = self._perform_get_request(endpoint_suffix)
logging.info("monitor_schema set to: ")
pp.pprint(self.knowledge.monitor_schema)

def get_execute_schema(self, endpoint_suffix = "execute_schema"):
self.knowledge.execute_schema = self._perform_get_request(endpoint_suffix)
logging.info("execute_schema set to: ")
pp.pprint(self.knowledge.execute_schema)

def get_adaptation_options_schema(self, endpoint_suffix: "API Endpoint" = "adaptation_options_schema"):
self.knowledge.adaptation_options_schema = self._perform_get_request(endpoint_suffix)
logging.info("adaptation_options_schema set to: ")
pp.pprint(self.knowledge.adaptation_options_schema)

def check_for_update_conflicts(self):
"""Examines knowledge.change_pipeline and returns conflicts if any exist.
Non-conflicting changes are placed into knowledge.plan_data."""
conflicts = []
temp_plan_data = {}
for update in self.knowledge.change_pipeline:
key = update["key"]
value = update["value"]
strategy = update["strategy"]
# If the value is already in the plan_data, we've found a conflict...
if key in temp_plan_data:
conflict = {
"original_strategy": temp_plan_data[key]["strategy"],
"new_strategy": strategy,
"key": key,
"original_value": temp_plan_data[key]["value"],
"new_value": value,
}
conflicts.append(conflict)

# Otherwise, add the key to the plan_data...
else:
temp_plan_data[key] = {"value": value, "strategy": strategy}
self.knowledge.plan_data[key] = value

# Once all conflicts have been noted, we have to remove all of the plan_data entries
# that were in conflict...
for conflict in conflicts:
if conflict["key"] in self.knowledge.plan_data:
del self.knowledge.plan_data[conflict["key"]]
return conflicts

def _perform_get_request(self, endpoint_suffix: "API Endpoint"):
url = '/'.join([self.base_endpoint, endpoint_suffix])
response = get_response_for_get_request(url)
if response.status_code == 404:
logging.error("Please check that the endpoint you are trying to reach actually exists.")
raise EndpointNotReachable
return response.json()

def ping(self):
ping_res = self._perform_get_request(self.base_endpoint)
logging.info(f"ping result: {ping_res}")

def start_container(self):
'''Starts running the docker container made from the given image when constructing this class'''
try:
Expand Down
52 changes: 52 additions & 0 deletions UPISAS/exemplars/demo_exemplar.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from time import sleep

from UPISAS import get_response_for_get_request
from UPISAS.exemplar import Exemplar
from UPISAS.exceptions import ServerNotReachable


class DemoExemplar(Exemplar):
Expand All @@ -15,3 +19,51 @@ def __init__(self, auto_start=False, container_name="upisas-demo"):

def start_run(self, app):
self.exemplar_container.exec_run(cmd = f' sh -c "cd /usr/src/app && node {app}" ', detach=True)

def stop_run(self):
self.stop_container(remove=False)

def stop_and_remove(self):
self.stop_container(remove=True)

def wait_for_server(self):
while True:
try:
get_response_for_get_request(self.base_endpoint)
break
except ServerNotReachable as e:
pass
sleep(1)


# =================================================================================================
# Fish Finder Exemplar
class DemoFishFinderExemplar(Exemplar):
"""
A class which encapsulates a self-adaptive exemplar run in a docker container.
"""
def __init__(self, auto_start=False, container_name="upisas-ff-demo"):
docker_config = {
"name": container_name,
"image": "joelmilligan/upisas-demo-managed-system",
"ports" : {3000: 3000}}

super().__init__("http://localhost:3000", docker_config, auto_start)

def start_run(self):
self.exemplar_container.exec_run(cmd = f' sh -c "cd /usr/src/app && node fish-finder.js" ', detach=True)

def stop_run(self):
self.stop_container(remove=False)

def stop_and_remove(self):
self.stop_container(remove=True)

def wait_for_server(self):
while True:
try:
get_response_for_get_request(self.base_endpoint)
break
except ServerNotReachable as e:
pass
sleep(1)
121 changes: 121 additions & 0 deletions UPISAS/goal_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from abc import ABC, abstractmethod
import logging
from UPISAS.exceptions import DockerImageNotFoundOnDockerHub
from UPISAS.knowledge import Knowledge
from UPISAS.strategy import Strategy

class GoalManagement(ABC):
"""
A class which determines a list of strategies to return based on the current state of the system and any potential encountered errors.
"""
def __init__(self,
exemplar: "Instantiated Exemplar object",
goals_dict: "dict of goals and their related strategies"):
"""
Initializes the GoalManagement object with the exemplar and goals_dict.

The goals_dict parameter is a dictionary with the following shape:
{
"goal1": {
"active": "(optional) boolean representing if the goal is active, defaults to true",
"priority": "(optional) int representing the priority of the goal",
"strategies": [strategy1, strategy2, ...]
},
"goal2": { ... },
...
}

Note that the strategies in the goals_dict should be instantiated objects of the Strategy class.
"""
self.exemplar = exemplar
self.goals_dict = goals_dict
self._triggers = {}
for goal, goal_dict in self.goals_dict.items():
goal_dict["active"] = goal_dict.get("active", True)
goal_dict["priority"] = goal_dict.get("priority", 0)
for strategy in goal_dict["strategies"]:
if not isinstance(strategy, Strategy):
raise TypeError(f"Strategy {strategy} is not an instance of Strategy")

def set_goal_trigger(self,
name: "str representing the trigger name. Must be unique.",
goal: "str representing the goal",
setTo: "bool representing the future state of the goal",
exceptionClass: "exception class representing the trigger"):
"""
Sets the trigger for the goal in the goals_dict.

Ex: When setting a goal, this method allows for goals to be automatically
triggered based on the state of the system. If the exception shows up in
the context object, then the trigger will be raised and the goal will be
activated by setting its "active" field to True.
"""
if goal not in self.goals_dict:
logging.error(f"Goal {goal} not found in goals_dict")
return

self._triggers[name] = {"goal": goal, "setTo": setTo, "exceptionClass": exceptionClass}

def remove_goal_trigger(self,
name: "str representing the trigger name"):
"""
Removes the trigger for the goal in the goals_dict.
"""
if name not in self._triggers:
logging.error(f"Trigger {name} not found in triggers")
return

del self._triggers[name]

def handle_errors(self, context: "dict with the current state of the system"):
"""
Updates the active state of the goals in goals_dict based on the context data.
"""
for trigger in self._triggers.values():
for error in context["errors"]:
if isinstance(error, trigger["exceptionClass"]):
self.goals_dict[trigger["goal"]]["active"] = trigger["setTo"]

def get_strategies(self,
context: "dict with the current state of the system"
) -> "list of Strategy objects":
"""
User-implemented method to return a list of strategies based on the current
state of the system. The context parameter is a dictionary with the current
state of the system, and should ideally contain the following shape:
{
"errors": "list of encountered errors",
"state": "current state of the system
(data other than knowledge, which is in self.exemplar.knowledge)",
}

If not overridden, this method will return a list of the active goal strategies.
"""
self.handle_errors(context)
strategies = []
for goal, goal_dict in self.goals_dict.items():
if goal_dict.get("active", True):
strategies.extend(goal_dict["strategies"])
return strategies

@abstractmethod
def resolve_conflicts(self,
conflicts: "list of dicts with the encountered conflicts between the strategies in the list"
) -> "list of Strategy objects":
"""
User-implemented method to resolve conflicts between the strategies in the
list. The strategies parameter is a list of Strategy objects, and should
update the exemplar knowledge plan_data prior to exiting.

The conflicts parameter is a list of dictionaries with the following shape:
[
{
"strategies": "list of strategies in conflict",
"key": "dict key in the plan_data that is in conflict",
"original_value": "value of the conflict",
"new_value": "new value to resolve the conflict",
},
...
]
"""
pass
Empty file.
Loading