diff --git a/jhack/main.py b/jhack/main.py index 21caa5d8..5ddc5e88 100755 --- a/jhack/main.py +++ b/jhack/main.py @@ -7,6 +7,7 @@ import typer + # this will make jhack find its modules if you call it directly (i.e. no symlinks) # aliases are OK sys.path.append(str(Path(os.path.realpath(__file__)).parent.parent)) @@ -54,6 +55,7 @@ def devmode_only(command): list_events, purge_db, ) + from jhack.utils.this_is_fine import this_is_fine from jhack.utils.ffwd import fast_forward from jhack.utils.just_deploy_this import just_deploy_this from jhack.utils.kill import kill @@ -71,6 +73,7 @@ def devmode_only(command): from jhack.utils.unbork_juju import unbork_juju from jhack.utils.unleash import vanity, vanity_2 from jhack.version import print_jhack_version + from jhack.utils.deployment_graph import unravel if "--" in sys.argv: sep = sys.argv.index("--") @@ -84,6 +87,7 @@ def devmode_only(command): utils.command(name="record", no_args_is_help=True)(record) utils.command(name="ffwd")(fast_forward) utils.command(name="print-env")(print_env) + utils.command(name="this-is-fine", no_args_is_help=True)(devmode_only(this_is_fine)) utils.command(name="unbork-juju")(devmode_only(unbork_juju)) utils.command(name="fire", no_args_is_help=True)(devmode_only(simulate_event)) @@ -132,6 +136,7 @@ def devmode_only(command): app.command(name="is", hidden=True)(vanity_2) app.command(name="jenv")(print_env) app.command(name="list-endpoints")(list_endpoints) + app.command(name="unravel")(unravel) # DEVMODE ONLY COMMANDS def _test_devmode(): diff --git a/jhack/utils/deployment_graph.py b/jhack/utils/deployment_graph.py new file mode 100644 index 00000000..0fbbf00c --- /dev/null +++ b/jhack/utils/deployment_graph.py @@ -0,0 +1,253 @@ +import dataclasses +import re +from code import interact +from typing import List, Dict, Optional, Tuple, Set + +import typer + +from jhack.helpers import juju_status, get_current_model, cached_juju_status + +from jhack.logger import logger as jhack_logger + +from jhack.utils.show_relation import ( + RelationEndpointURL, + Relation, + gather_relation_databags, + AppRelationData, + get_relation_by_endpoint, + get_unit_info, +) + +logger = jhack_logger.getChild("graph") + +identifier = "[a-zA-Z-_0-9]" +RELATIONS_STATUS_RE = re.compile( + f"(?P{identifier}+):(?P{identifier}+)\s+" + f"(?P{identifier}+):(?P{identifier}+)\s+" + f"(?P{identifier}+)\s+(?P{identifier}+)" + f"[\s+(?P.+)]?" +) + + +@dataclasses.dataclass(frozen=True) +class _App: + name: str + model: str + + # raw juju status | jq | .applications[name] + meta: Dict + + @property + def scale(self): + return len(self.meta["units"]) + + def __hash__(self): + return hash((self.model, self.name)) + + @property + def charm_name(self): + return self.meta["charm-name"] + + +class Graph: + """Graph type.""" + + def __init__(self, graph: Dict[_App, List[Relation]], model: str): + self._include_default_juju_keys = True + self._graph = graph + self._model = model + self._relation_data: Dict[Relation, Tuple[AppRelationData, ...]] = {} + + def get_relation_data(self, relation: Relation): + if relation.id is None: + raise ValueError(relation) + + if not self._relation_data.get(relation): + self._relation_data[relation] = gather_relation_databags( + RelationEndpointURL( + f"{relation.requirer}:{relation.requirer_endpoint}" + ), + RelationEndpointURL( + f"{relation.provider}:{relation.provider_endpoint}" + ), + relation, + model=self._model, + include_default_juju_keys=self._include_default_juju_keys, + ) + + return self._relation_data[relation] + + @staticmethod + def bootstrap(app_name: Optional[str] = None, model_name: str = None) -> "Graph": + """Bootstrap a graph. + + From a single starting app url, or all of them otherwise. + + Example: + >>> Graph.bootstrap("alertmanager/0", "microk8s-localhost:clite") + >>> Graph.bootstrap(model_name="microk8s-localhost:clite") + """ + if app_name: + if "/" in app_name: + logger.warning( + f"stripping unit ID suffix from {app_name}. Pass an app name instead." + ) + app_name = app_name.split("/")[0] + + print(f"Bootstrapping graph from root: {model_name}.{app_name}") + else: + print(f"Bootstrapping graph in model {model_name}") + + model_status_cache = {} + + def get_status(model_name_, json: bool): + if (model_name_, json) not in model_status_cache: + model_status_cache[(model_name_, json)] = juju_status( + model=model_name_, json=json + ) + return model_status_cache[(model_name_, json)] + + def get_app(app_name_, model_name_, status=None): + status = status or get_status(model_name_, json=True) + app_meta = status["applications"][app_name_] + return _App(name=app_name_, model=model_name_, meta=app_meta) + + visited_applications: Set[str] = set() + visited_relations: Set[Relation] = set() + + def walk(model_name_: str, app_name_: str, graph_=None): + if app_name_ in visited_applications: + return graph_ + + visited_applications.add(app_name_) + + model_status_raw = get_status(model_name_, json=False) + model_relations = RELATIONS_STATUS_RE.findall(model_status_raw) + + app = get_app(app_name_, model_name_) + relations: List[Relation] = [] + graph_[app] = relations + + offers_meta = status.get("application-endpoints", ()) + + for endpoint, bindings in app.meta["relations"].items(): + for binding in bindings: + remote_app_name = binding["related-application"] + + if remote_app_name in offers_meta: + # CMR + remote_app_meta = offers_meta[remote_app_name] + # url is in the form 'localhost-localhost:admin/gagent1.gagent' + remote_model_name = remote_app_meta["url"].split(".")[0] + remote_app = get_app(remote_app_name, remote_model_name) + else: + remote_model_name = model_name_ + remote_app = get_app(remote_app_name, model_name_) + remote_app_meta = remote_app.meta + + if remote_app not in graph_: + walk(remote_model_name, remote_app_name, graph_) + + remote_endpoint = _find_remote_endpoint( + remote_app_meta, app_name_, binding["interface"] + ) + + if remote_model_name == model_name_: + # todo subordinate check + if remote_app_name == app_name_: + relation_type = "peer" + else: + relation_type = "regular" + else: + relation_type = "cross_model" + + # todo compare provider, provider-endpoint, requirer.... with the raw relation data + + rel_footprint = ( + app_name_, + endpoint, + remote_app_name, + remote_endpoint, + binding["interface"], + ) + if rel_footprint not in model_relations: + continue + + rel = Relation( + provider=app_name_, + provider_endpoint=endpoint, + requirer=remote_app_name, + requirer_endpoint=remote_endpoint, # todo + interface=binding["interface"], + raw_type=relation_type, + id=_find_relation_id( + app_name_, + model_name_, + endpoint, + remote_endpoint, + remote_app_name, + ), + ) + + if rel in visited_relations: + continue + + relations.append(rel) + visited_relations.add(rel) + + return graph_ + + model_ = model_name or get_current_model() + + if not app_name: + graph = {} + cached_status = cached_juju_status(model=model_, json=True) + for app_name in cached_status["applications"]: + graph = walk(model_, app_name, graph) + + else: + graph = walk(model_, app_name, {}) + + return Graph(graph, model=model_) + + def plot(self): + print("GRAPH:") + for origin, relations in self._graph.items(): + print(f"\t{origin.name} ({origin.charm_name}) :: {{") + for relation in relations: + print( + f"\t\t({relation.provider}) {relation.provider_endpoint} >> " + f"{relation.requirer_endpoint} ({relation.requirer})" + ) + if reldata := self.get_relation_data(relation): + print( + f"\t\t\tRelation found: " + f"{reldata[0].url} --> " + f"{reldata[1].url if len(reldata) == 2 else ''} " + f"({relation.id})" + ) + print(f"\t}}") + + +def _map(app_name: str, model_name: str): + graph = Graph.bootstrap(app_name=app_name, model_name=model_name) + graph.plot() + + +def unravel( + app_name: Optional[str] = typer.Argument( + None, help="""The starting point of the graph expansion.""" + ), + model_name: str = typer.Option( + None, + "-m", + "--model", + help="The model in which to find the app from which to start the unraveling.", + ), +): + _map(app_name=app_name, model_name=model_name) + + +if __name__ == "__main__": + # jhack unravel traefik/0 + Graph.bootstrap("tempo/0").plot() diff --git a/jhack/utils/show_relation.py b/jhack/utils/show_relation.py index 070b2b93..f96a7845 100644 --- a/jhack/utils/show_relation.py +++ b/jhack/utils/show_relation.py @@ -51,11 +51,25 @@ class Relation: requirer_endpoint: str interface: str raw_type: str + id: Optional[int] = None @property def type(self) -> RelationType: return RelationType(self.raw_type) + def __hash__(self): + return hash( + ( + self.id or 0, + self.raw_type, + self.interface, + self.provider, + self.provider_endpoint, + self.requirer, + self.requirer_endpoint, + ) + ) + class RelationEndpointURL(str): def __init__(self, s): @@ -753,7 +767,7 @@ def pl(condition, a="", b=""): return ep_url_1, ep_url_2, relation -def _gather_entities( +def gather_relation_databags( endpoint1: RelationEndpointURL, endpoint2: Optional[RelationEndpointURL], relation: Relation, @@ -811,7 +825,7 @@ async def render_relation( if endpoint1.app_name in saas or (endpoint2 and endpoint2.app_name in saas): relation.raw_type = "cross_model" - entities = _gather_entities( + entities = gather_relation_databags( endpoint1, endpoint2, relation, diff --git a/jhack/utils/this_is_fine.py b/jhack/utils/this_is_fine.py new file mode 100644 index 00000000..bb1feae3 --- /dev/null +++ b/jhack/utils/this_is_fine.py @@ -0,0 +1,115 @@ +import time +from subprocess import getoutput, CalledProcessError +from typing import List, Optional + +import typer + +from jhack.conf.conf import check_destructive_commands_allowed +from jhack.helpers import juju_status +from jhack.logger import logger as jhack_logger + +logger = jhack_logger.getChild("tif") + + +def _this_is_fine( + targets: List[str] = None, + model: str = None, + keep_alive: bool = False, + retry: bool = False, + dry_run: bool = False, + rate: float = 2, +): + check_destructive_commands_allowed("this-is-fine") + print("starting whacker... (interrupt with Ctrl+C)") + + while True: + start = time.time() + status = juju_status(model=model, json=True) + units_in_error = [] + + for app_name, app_meta in status["applications"].items(): + included = not targets or app_name in targets + + for unit_name, unit_meta in app_meta["units"].items(): + if targets and unit_name in targets: + included = True + + if not included: + continue + + wl_status = unit_meta["workload-status"]["current"] + jj_status = unit_meta["juju-status"]["current"] + if wl_status == "error" and jj_status == "idle": + units_in_error.append(unit_name) + + if units_in_error: + print(f"apps {units_in_error} in error") + else: + logger.debug("no apps in error") + if not keep_alive: + return + + for unit in units_in_error: + _model = f" -m {model}" if model else "" + cmd = f"juju resolve{' --no-retry' if not retry else ''}{_model} {unit}" + if dry_run: + print(f"would tell {unit} to stop whining with {cmd}") + else: + if retry: + print(f"\t{unit} is giving it another shot") + else: + print(f"\t{unit} is now *fine*") + try: + getoutput(cmd) + except CalledProcessError: + logger.exception(f"{cmd} bugged out") + continue + + elapsed = start - time.time() + time.sleep(max(rate - elapsed, 0)) + + +def this_is_fine( + targets: Optional[List[str]] = typer.Argument( + None, + help="The app, apps or units that are now *fine*. If left blank, will include all apps and units.", + ), + model: str = typer.Option( + None, "-m", "--model", help="Model to which to apply this command." + ), + keep_alive: bool = typer.Option( + False, + "-k", + "--keep-alive", + is_flag=True, + help="Keep the process alive instead of exiting as soon as all units in error have been told that life is good.", + ), + retry: bool = typer.Option( + False, + "-r", + "--retry", + is_flag=True, + help="Whether juju should retry the last failed hook or not.", + ), + dry_run: bool = typer.Option( + False, + help="Don't actually do anything, just print out what would have been done.", + ), + rate: float = typer.Option( + 2, + help="Minimum sleep between successive runs, only applicable in keep-alive mode.", + ), +): + """This command tells all your units and applications that things are good.""" + return _this_is_fine( + targets=targets or [], + model=model, + keep_alive=keep_alive, + retry=retry, + dry_run=dry_run, + rate=rate, + ) + + +if __name__ == "__main__": + _this_is_fine(keep_alive=True) diff --git a/pyproject.toml b/pyproject.toml index e6077097..2d76fe33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ dependencies = [ "requests-unixsocket(==0.3.0)", "asttokens", "astunparse", + "networkx(==3.1)", + "matplotlib(==3.7.5)", + "pyqt5(>=5.15.9)", "toml", ] classifiers = [