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

Hook context: Add a clear boundary between ops and juju #1075

Open
sed-i opened this issue Nov 22, 2023 · 4 comments
Open

Hook context: Add a clear boundary between ops and juju #1075

sed-i opened this issue Nov 22, 2023 · 4 comments
Labels
24.10 needs design needs more thought or spec

Comments

@sed-i
Copy link
Contributor

sed-i commented Nov 22, 2023

Hook tools (1, 2) and pebble.py already draw a pretty clear boundary between ops and juju, but there is a key part missing: environment variables.

Wouldn't it be nice if the juju context could be represented as a plain, flat class directly from envvars, and without any deps? And then reused within ops for whatever purpose, without ever again hardcoding envvar string literals?

Conceptual design for hook context

# ops/juju_context.py

import os


class JujuContext:
    def __init__(self, context: dict):
        self.model = context.get("JUJU_MODEL_NAME")
        self.model_uuid = context.get("JUJU_MODEL_UUID")
        self.unit_name = context.get("JUJU_APP_NAME")
        self.app_name, self.unit_id = self.unit_name.split("/")
        self.juju_version = context.get("JUJU_VERSION")
        self.hook_name = context.get("JUJU_HOOK_NAME")


class InstallContext(JujuContext):
    pass


class LeaderElectedContext(JujuContext):
    pass


# Same for start, config-changed, update-status, stop, remove


class PebbleReadyContext(JujuContext):
    def __init__(self, context: dict):
        super().__init__(context)
        self.workload_name = context["JUJU_WORKLOAD_NAME"]


class RelationContext(JujuContext):
    def __init__(self, context: dict):
        super().__init__(context)
        self.relation_name = context["JUJU_RELATION"]
        self.remote_app_name = context["JUJU_REMOTE_APP"]
        self.relation_id = context["JUJU_RELATION_ID"].split(":")[1]

    @property
    def remote_app_data(self):
        return "hook_tools.relation_get(self.relation_id, self.remote_app_name, is_app=True)"

    @property
    def local_app_data(self):
        return "hook_tools.relation_get(self.relation_id, self.unit_name, is_app=True)"


class RelationCreatedContext(RelationContext):
    pass


class RelationJoinedContext(RelationContext):
    def __init__(self, context: dict):
        super().__init__(context)
        self.remote_unit_name = context["JUJU_REMOTE_UNIT"]

    @property
    def remote_unit_data(self):
        return "hook_tools.relation_get(self.relation_id, self.remote_unit_name, is_app=False)"


# Similar for the other events


def context_from_dict(env: dict) -> JujuContext:
    ctx = JujuContext(env)
    if ctx.hook_name == "install":
        return InstallContext(env)
    elif ctx.hook_name == "leader-elected":
        return LeaderElectedContext(env)
    elif ctx.hook_name == f"{env.get('JUJU_WORKLOAD_NAME')}-pebble-ready":
        return PebbleReadyContext(env)
    elif ctx.hook_name == f"{env.get('JUJU_RELATION')}-relation-created":
        return RelationCreatedContext(env)
    # etc.
    else:
        return ctx


def context_from_environ() -> JujuContext:
    return context_from_dict(dict(os.environ))

Here's how a charm could look like

from ops.juju_context import context_from_environ, PebbleReadyContext, RelationJoinedContext

if __name__ == "__main__":
    current_hook = context_from_environ()
    if isinstance(current_hook, PebbleReadyContext):
        print("pebble ready for", current_hook.workload_name)
    elif isinstance(current_hook, RelationJoinedContext):
        print("relation data:", current_hook.remote_unit_data)

Advantages

  • Simplicity.
  • Better clarity for the reader for how things work, where juju ends and ops begins.
  • Static guarantees for the presence of context members such as workload (pebble ready) or remote unit (relation joined, changed or departed; on relation created and broken there is no remote unit, only remote app).
  • Less Optional in type hints (?)

Far fetched musings

  • No need to go "all in" on ops.
  • Could potentially obviate framework.observe().
  • No defer, no snapshot, no restore.
  • Can abandon custom events and adopt manifest driven charming.
@PietroPasotti
Copy link
Contributor

imagine what you can do with that plus access to a holistic scenario.State:

https://github.com/PietroPasotti/jhack/blob/main/jhack/scenario/integrations/darkroom.py

if __name__ == '__main__':
     from darkroom import Darkroom
     state: scenario.State = Darkroom.capture(backend=ops._ModelBackend.from_context(...))

     if state.relations: ...
     
     if state.leader: ...

@benhoyt
Copy link
Collaborator

benhoyt commented Nov 23, 2023

I agree this would be a nice separation (though I'm not convinced we'd want to make it public / a guaranteed part of the API). I think main.py is a bit messy right now and this would help separate out the concerns.

It also seems to me this is bound up with the event type, and there's a fair bit of overlap between RelationContext and RelationEvent, for example. Hmmm, worth further thought.

@sed-i
Copy link
Contributor Author

sed-i commented Feb 7, 2024

Circled back to this issue today. Here's the summary:

  • pebble changes on prometheus showed a TON of Execute command "update-ca-certificates".
  • Turns out I added it to charm's __init__ to circumvent ordering issues with a custom event self.cert_handler.on.cert_changed.
  • Hooks are processed in the order of the observe statements, but it's quite implicit and bears unexpected behavior when someone refactors and changes some previously assumed order.
  • Would have been nice if hook order was explicit at __init__. For example: at the very top of __init__ I could: if context in [Upgrade, SomeRelationChanged, SomeOtherRelChanged]: self._update_cert().

Related: https://discourse.charmhub.io/t/charming-without-any-observers/13120

@benhoyt
Copy link
Collaborator

benhoyt commented May 17, 2024

This is a roadmap item for 24.10. We'll implement something like this, though not necessarily with this exact API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
24.10 needs design needs more thought or spec
Projects
None yet
Development

No branches or pull requests

3 participants