Repository: canonical/operator
Pull request #1313 (2024-08-13 02:57:25)

feat: parse JUJU_* env in one place
Parse all `JUJU_*` env vars in one place.

Closes https://github.com/canonical/operator/issues/1075.

---

Note: This draft does the minimum changes by creating a `dataclass`, no other logic in `main.py` is changed. Variables are not grouped into smaller `dataclasses`, and all types are string just as they were in `os.environ.get()`.

There will be another alternative draft with variable grouping and typing.

---

## More Explanations

### 1 Grouping

I think it's fine without it but I'm not against it either. If there is a solid reason for grouping, I'm OK with it, but here are my thoughts:

1. Value added: It seems only `JUJU_NOTICE_*` and `JUJU_SECRET_*` can be grouped into a smaller dataclass. Maybe we can argue that `JUJU_RELATION` and `JUJU_RELATION_ID` can be grouped too, but then the first needs to be renamed to `JUJU_RELATION_NAME` which deviates from the acutal env variable name. So, most env vars can't really be grouped, which means the `_JujuContext` dataclass can't be shortened or improved readability-wise by grouping. And when using it, it's not too much difference, comparing `juju_context_obj.juju_notice_id` and `juju_context_obj.juju_notice.id`. So I think the value added by grouping is insignificant.
2. Logic: Without grouping, all variables are on the same level in parallel, similar to each other. If we group like `_JujuContext.notice.id` and `_JujuContext.juju_charm_dir`, it might indicate that `_JujuContext` owns `notice` and `juju_charm_dir`, they are on the same level, and there is another object that is `notice` who owns `id`, while in fact it's not the case, they are all env vars.
3. Last but not least, the source comes from [here](https://github.com/juju/juju/blob/3.6/worker/uniter/runner/context/context.go#L1394) where it's just a list of key/value pairs with no grouping, so I think maybe it's better to stay the same.

### 2 Type

I put all fields as str in this draft, because:

1. I wanted to put some fields as int in the first place but after investigating, only very few fields are actually non-str, like [`JUJU_SECRET_REVISION`](https://github.com/canonical/operator/blob/main/ops/main.py#L180); and some values are processed based on the env var, like [juju relation id](https://github.com/canonical/operator/blob/main/ops/main.py#L151). So, maybe it's easier to keep them as they are and handle the type conversion whenever needed (mostly aren't). There are on extra plus side and that is we don't need to do extra processing if the key is `JUJU_SECRET_REVISION`, keeping the `from_env` method simple without key-name related logic.
2. Since the whole `_JujuContext` dataclass comes from env vars, it's intuitive to think all fields are strings.
3. The [source](https://github.com/juju/juju/blob/3.6/worker/uniter/runner/context/context.go#L1394) is all strings.


Some explanation on `Optional` or not, and default values, becaues it may seem strange at first glace, and these logic is not from Juju but from `ops.main`, how we are using them currently:

1. `Optional`: Some fields are not `Optional` because in `main.py` the current usage of them is treating them like they surely exist (`os.environ[key]`), so I kept them as type `str` instead of `Optional[str]`.
2. Some fields must be `Optional` with None value because in the current logic there are tests like `if key in os.environ`.
3. Some fields are used as args to pass to other things and they can be `None` so I left these type all as `Optional` that can be `None`.


tonyandrewmeyer: For what it's worth, I agree that it's not worth grouping (flat is better than nested). However, I do think it's worth doing the type conversion in the class, as well as any `split` type logic.

I'd also suggest that the `juju_` prefix for the attribute names isn't needed, since it's implied by the fact that it's in `_JujuContext`.
dimaqq: Consider

```
	// relationId identifies the relation for which a relation hook is
	// executing. If it is -1, the context is not running a relation hook;
	// otherwise, its value must be a valid key into the relations map.
	relationId int
```

If we're going to cast or interpret the values, we gotta handle these special cases too. In this case, I think `-1 ==> None`.
IronCore864: After discussion, we will use a flat structure without nested objects, and we will put all fields as `Optional[str]` or `Optional[int]`.
IronCore864: Notes:

1. `_JujuContext.from_environ(dict(os.environ))` looks weird, because strictly speaking, it's not `from_environ` but `from_dict`. The name `from_environ` is used to be consistent with a few other usages in the code base; a `Dict` is used instead of `os._Environ` per previous code review suggestions to make it easy to be mocked in tests.
2. Where to put `_JujuContext`: I think it makes sense to put it in `main.py` because it's first used there. But no matter where I put it, I can't bypass circular imports in `jujuversion.py`, so I left it where it's most logical and used `TYPE_CHECKING` to fix the import for typing checks. However, this breaks sphinx doc build. _How can we solve this?_

Changes:


`_JujuContext`:

- Every field is of type `Optional[str]` or `Optional[int]` and defaults to `None`, according to the code review and our daily discussion.
- The only two int-type fields are `relation_id` and `secret_revision`, where I did not handle the case that the env var's value exists but is not a number. Should we handle this case? Or let it throw a `ValueError` is OK?
- A few more env vars used in `model.py` and `JujuVersion` are added, except `JUJU_DEBUG_AT` (used in `framework.py`) and `OPERATOR_DISPATCH` (used in `main.py`), because they are not in the source [here](https://juju.is/docs/juju/charm-environment-variables) and [here](https://github.com/juju/juju/blob/3.6/worker/uniter/runner/context/context.go#L1398).
- `_JujuContext` is instantiated only once in `_Manager`, then passed to `_Dispatcher` and `_ModelBackend`, the latter also passes it to `_Container`. These classes will pass it to `JujuVersion` too.

`JujuVersion`:

- The original `from_environ` method is renamed to `from_context` because it's not appropriate any more. `from_juju_context` was also considered, but `JujuVersion.from_juju_context(juju_context)` seemed too verbose and duplicated, hence `JujuVersion.from_context(juju_context)`.
- The original `if v is None: v = '0.0.0` logic is removed; default value is handled now in `_JujuContext`.


`model.py`:

- There were a few `os.environ` / `os.getenv` in it, all refactored to use `_JujuContext`.

`test/*`:

- Add tests for `JujuVersion.from_context`.
- Refactor tests to make them pass.
- Add tests for `_JujuContext.from_environ`.

IronCore864: db-charm-tests failed because [the charm imports JujuVersion from `ops.model`](https://github.com/canonical/postgresql-k8s-operator/blob/main/src/charm.py#L63) instead of `ops.jujuversion`.
benhoyt: Oh, one more thing I noticed, this use of `JUJU_DEBUG_AT` in framework.py:

```python
        # Parse the env var once, which may be used multiple times later
        debug_at = os.environ.get('JUJU_DEBUG_AT')
        if debug_at:
            self._juju_debug_at = {x.strip() for x in debug_at.split(',')}
        else:
            self._juju_debug_at: Set[str] = set()
```

Should this go in `_JujuContext` too?

Patch:
diff --git a/HACKING.md b/HACKING.md
index e9218b6..a61932c 100644
--- a/HACKING.md
+++ b/HACKING.md
@@ -190,6 +190,12 @@ Note that the commit messages to the PR's branch do not need to follow the
 conventional commit format, as these will be squashed into a single commit to `main`
 using the PR title as the commit message.
 
+## Copyright
+
+The format for copyright notices is documented in the [LICENSE.txt](LICENSE.txt).
+New files should begin with a copyright line with the current year (e.g. Copyright 2024 Canonical Ltd.) and include the full boilerplate (see APPENDIX of [LICENSE.txt](LICENSE.txt)).
+The copyright information in existing files does not need to be updated when those files are modified -- only the initial creation year is required.
+
 # Documentation
 
 In general, new functionality
diff --git a/docs/conf.py b/docs/conf.py
index e0cceed..fd4e82f 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,4 +1,3 @@
-# ruff: noqa
 import sys
 import os
 
@@ -99,6 +98,12 @@ exclude_patterns = [
 ]
 exclude_patterns.extend(custom_excludes)
 
+rst_epilog = '''
+.. include:: /reuse/links.txt
+'''
+if 'custom_rst_epilog' in locals():
+    rst_epilog = custom_rst_epilog
+
 source_suffix = {
     '.rst': 'restructuredtext',
     '.md': 'markdown',
diff --git a/docs/custom_conf.py b/docs/custom_conf.py
index 2cbd9fd..29e9643 100644
--- a/docs/custom_conf.py
+++ b/docs/custom_conf.py
@@ -1,4 +1,17 @@
-# ruff: noqa
+# Copyright 2019 Canonical Ltd.
+#
+# 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 datetime
 import pathlib
 import sys
@@ -37,6 +50,7 @@ sphinx.ext.autodoc.py_ext_sig_re = re.compile(
     r"""^ ([\w.]+::)?            # explicit module name
           ([\w.]+\.)?            # module and/or class name(s)
           ([^.()]+)  \s*         # thing name
+          (?: \[\s*(.*)\s*])?    # optional: type parameters list, Sphinx 7&8
           (?: \((.*)\)           # optional: arguments
            (?:\s* -> \s* (.*))?  #           return annotation
           )? $                   # and nothing more
@@ -85,7 +99,7 @@ html_title = project + ' documentation'
 #   -H 'Accept: application/vnd.github.v3.raw' \
 #   https://api.github.com/repos/canonical/<REPO> | jq '.created_at'
 
-copyright = '%s, %s' % (datetime.date.today().year, author)
+copyright = '%s, %s' % (datetime.date.today().year, author)  # noqa: A001
 
 ## Open Graph configuration - defines what is displayed as a link preview
 ## when linking to the documentation from another website (see https://ogp.me/)
@@ -220,7 +234,7 @@ custom_html_js_files = []
 # Specify a reST string that is included at the end of each file.
 # If commented out, use the default (which pulls the reuse/links.txt
 # file into each reST file).
-# custom_rst_epilog = ''
+custom_rst_epilog = ''
 
 # By default, the documentation includes a feedback button at the top.
 # You can disable it by setting the following configuration to True.
@@ -394,7 +408,7 @@ class JujuVersion(SphinxDirective):
         domain = self.env.get_domain('changeset')
         domain.note_changeset(node)
 
-        ret: list[Node] = [node]
+        ret = [node]
         ret += messages
         return ret
 
diff --git a/docs/requirements.txt b/docs/requirements.txt
index e4ea683..0df7bf3 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,40 +1,54 @@
 #
-# This file is autogenerated by pip-compile with Python 3.11
+# This file is autogenerated by pip-compile with Python 3.12
 # by the following command:
 #
 #    pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml
 #
-alabaster==0.7.16
+alabaster==1.0.0
     # via sphinx
-babel==2.14.0
+anyio==4.4.0
+    # via
+    #   starlette
+    #   watchfiles
+babel==2.16.0
     # via sphinx
 beautifulsoup4==4.12.3
     # via
     #   canonical-sphinx-extensions
     #   furo
     #   pyspelling
-bracex==2.4
+bracex==2.5
     # via wcmatch
-canonical-sphinx-extensions==0.0.20
+canonical-sphinx-extensions==0.0.23
     # via ops (pyproject.toml)
 certifi==2024.7.4
     # via requests
 charset-normalizer==3.3.2
     # via requests
+click==8.1.7
+    # via uvicorn
 colorama==0.4.6
     # via sphinx-autobuild
-docutils==0.19
+docutils==0.21.2
     # via
     #   canonical-sphinx-extensions
     #   myst-parser
     #   sphinx
     #   sphinx-tabs
-furo==2024.1.29
+furo==2024.8.6
     # via ops (pyproject.toml)
+gitdb==4.0.11
+    # via gitpython
+gitpython==3.1.43
+    # via canonical-sphinx-extensions
+h11==0.14.0
+    # via uvicorn
 html5lib==1.1
     # via pyspelling
 idna==3.7
-    # via requests
+    # via
+    #   anyio
+    #   requests
 imagesize==1.4.1
     # via sphinx
 jinja2==3.1.4
@@ -43,11 +57,9 @@ jinja2==3.1.4
     #   sphinx
 linkify-it-py==2.0.3
     # via ops (pyproject.toml)
-livereload==2.6.3
-    # via sphinx-autobuild
-lxml==5.2.1
+lxml==5.3.0
     # via pyspelling
-markdown==3.6
+markdown==3.7
     # via pyspelling
 markdown-it-py==3.0.0
     # via
@@ -55,41 +67,43 @@ markdown-it-py==3.0.0
     #   myst-parser
 markupsafe==2.1.5
     # via jinja2
-mdit-py-plugins==0.4.0
+mdit-py-plugins==0.4.1
     # via myst-parser
 mdurl==0.1.2
     # via markdown-it-py
-myst-parser==2.0.0
+myst-parser==4.0.0
     # via ops (pyproject.toml)
-packaging==24.0
+packaging==24.1
     # via sphinx
-pygments==2.17.2
+pygments==2.18.0
     # via
     #   furo
     #   sphinx
     #   sphinx-tabs
 pyspelling==2.10
     # via ops (pyproject.toml)
-pyyaml==6.0.1
+pyyaml==6.0.2
     # via
     #   myst-parser
     #   ops (pyproject.toml)
     #   pyspelling
-requests==2.32.0
+requests==2.32.3
     # via
     #   canonical-sphinx-extensions
     #   sphinx
 six==1.16.0
-    # via
-    #   html5lib
-    #   livereload
+    # via html5lib
+smmap==5.0.1
+    # via gitdb
+sniffio==1.3.1
+    # via anyio
 snowballstemmer==2.2.0
     # via sphinx
-soupsieve==2.5
+soupsieve==2.6
     # via
     #   beautifulsoup4
     #   pyspelling
-sphinx==6.2.1
+sphinx==8.0.2
     # via
     #   canonical-sphinx-extensions
     #   furo
@@ -103,43 +117,49 @@ sphinx==6.2.1
     #   sphinx-tabs
     #   sphinxcontrib-jquery
     #   sphinxext-opengraph
-sphinx-autobuild==2024.2.4
+sphinx-autobuild==2024.4.16
     # via ops (pyproject.toml)
 sphinx-basic-ng==1.0.0b2
     # via furo
 sphinx-copybutton==0.5.2
     # via ops (pyproject.toml)
-sphinx-design==0.5.0
+sphinx-design==0.6.1
     # via ops (pyproject.toml)
-sphinx-notfound-page==1.0.0
+sphinx-notfound-page==1.0.4
     # via ops (pyproject.toml)
 sphinx-tabs==3.4.5
     # via ops (pyproject.toml)
-sphinxcontrib-applehelp==1.0.8
+sphinxcontrib-applehelp==2.0.0
     # via sphinx
-sphinxcontrib-devhelp==1.0.6
+sphinxcontrib-devhelp==2.0.0
     # via sphinx
-sphinxcontrib-htmlhelp==2.0.5
+sphinxcontrib-htmlhelp==2.1.0
     # via sphinx
 sphinxcontrib-jquery==4.1
     # via ops (pyproject.toml)
 sphinxcontrib-jsmath==1.0.1
     # via sphinx
-sphinxcontrib-qthelp==1.0.7
+sphinxcontrib-qthelp==2.0.0
     # via sphinx
-sphinxcontrib-serializinghtml==1.1.10
+sphinxcontrib-serializinghtml==2.0.0
     # via sphinx
 sphinxext-opengraph==0.9.1
     # via ops (pyproject.toml)
-tornado==6.4.1
-    # via livereload
+starlette==0.38.2
+    # via sphinx-autobuild
 uc-micro-py==1.0.3
     # via linkify-it-py
 urllib3==2.2.2
     # via requests
-wcmatch==8.5.1
+uvicorn==0.30.6
+    # via sphinx-autobuild
+watchfiles==0.23.0
+    # via sphinx-autobuild
+wcmatch==9.0
     # via pyspelling
 webencodings==0.5.1
     # via html5lib
-websocket-client==1.7.0
+websocket-client==1.8.0
     # via ops (pyproject.toml)
+websockets==12.0
+    # via sphinx-autobuild
diff --git a/ops/_private/__init__.py b/ops/_private/__init__.py
index 6fb08cc..846ff8c 100644
--- a/ops/_private/__init__.py
+++ b/ops/_private/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2024 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/ops/charm.py b/ops/charm.py
index 664cbc6..5862b39 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2021 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/ops/framework.py b/ops/framework.py
index e5caf0d..7c96ee5 100644
--- a/ops/framework.py
+++ b/ops/framework.py
@@ -1,4 +1,4 @@
-# Copyright 2020-2021 Canonical Ltd.
+# Copyright 2020 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -20,7 +20,6 @@ import inspect
 import keyword
 import logging
 import marshal
-import os
 import pathlib
 import pdb
 import re
@@ -604,6 +603,7 @@ class Framework(Object):
         meta: 'charm.CharmMeta',
         model: 'Model',
         event_name: Optional[str] = None,
+        juju_debug_at: Optional[Set[str]] = None,
     ):
         super().__init__(self, None)
 
@@ -618,7 +618,6 @@ class Framework(Object):
         if event_name:
             event_name = event_name.replace('-', '_')
         self._event_name = event_name
-
         self.meta = meta
         self.model = model
         # [(observer_path, method_name, parent_path, event_key)]
@@ -650,13 +649,7 @@ class Framework(Object):
 
         # Flag to indicate that we already presented the welcome message in a debugger breakpoint
         self._breakpoint_welcomed: bool = False
-
-        # Parse the env var once, which may be used multiple times later
-        debug_at = os.environ.get('JUJU_DEBUG_AT')
-        if debug_at:
-            self._juju_debug_at = {x.strip() for x in debug_at.split(',')}
-        else:
-            self._juju_debug_at: Set[str] = set()
+        self._juju_debug_at = juju_debug_at or set()
 
     def set_breakpointhook(self) -> Optional[Any]:
         """Hook into ``sys.breakpointhook`` so the builtin ``breakpoint()`` works as expected.
diff --git a/ops/jujucontext.py b/ops/jujucontext.py
new file mode 100644
index 0000000..df33503
--- /dev/null
+++ b/ops/jujucontext.py
@@ -0,0 +1,229 @@
+# Copyright 2024 Canonical Ltd.
+#
+# 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.
+
+"""A helper to work with the Juju context and version."""
+
+import dataclasses
+from pathlib import Path
+from typing import Any, Mapping, Optional, Set
+
+from ops.jujuversion import JujuVersion
+
+
+@dataclasses.dataclass(frozen=True)
+class _JujuContext:
+    """_JujuContext collects information from environment variables named 'JUJU_*'.
+
+    Source: https://juju.is/docs/juju/charm-environment-variables.
+    The HookVars function: https://github.com/juju/juju/blob/3.6/worker/uniter/runner/context/context.go#L1398.
+    Only a subset of the above source, because these are what are used in ops.
+    """
+
+    action_name: Optional[str] = None
+    """The action's name.
+
+    For example 'backup' (from JUJU_ACTION_NAME).
+    """
+
+    action_uuid: Optional[str] = None
+    """The action's uuid.
+
+    For example '1' (from JUJU_ACTION_UUID).
+    """
+
+    charm_dir: Path = dataclasses.field(
+        default_factory=lambda: Path(f'{__file__}/../../..').resolve()
+    )
+    """The root directory of the charm where it is running.
+
+    For example '/var/lib/juju/agents/unit-bare-0/charm' (from JUJU_CHARM_DIR).
+
+    If JUJU_CHARM_DIR is None or set to an empty string, use Path(f'{__file__}/../../..') as
+    default (assuming the '$JUJU_CHARM_DIR/lib/op/main.py' structure).
+    """
+
+    debug: bool = False
+    """Debug mode.
+
+    If true, write logs to stderr as well as to juju-log (from JUJU_DEBUG).
+    """
+
+    debug_at: Set[str] = dataclasses.field(default_factory=set)
+    """Where you want to stop when debugging.
+
+    For example 'all' (from JUJU_DEBUG_AT).
+    """
+
+    dispatch_path: str = ''
+    """The dispatch path in the format of 'actions/do-something'.
+
+    For example 'hooks/workload-pebble-ready' (from JUJU_DISPATCH_PATH).
+    """
+
+    model_name: str = ''
+    """The name of the model.
+
+    For example 'foo' (from JUJU_MODEL_NAME).
+    """
+
+    model_uuid: str = ''
+    """The uuid of the model.
+
+    For example 'cdac5656-2423-4388-8f30-41854b4cca7d' (from JUJU_MODEL_UUID).
+    """
+
+    notice_id: Optional[str] = None
+    """The ID of the notice.
+
+    For example '1', (from JUJU_NOTICE_ID).
+    """
+
+    notice_key: Optional[str] = None
+    """The key of the notice.
+
+    For example 'example.com/a', (from JUJU_NOTICE_KEY).
+    """
+
+    notice_type: Optional[str] = None
+    """The type of the notice.
+
+    For example 'custom' (from JUJU_NOTICE_TYPE).
+    """
+
+    pebble_check_name: Optional[str] = None
+    """The name of the pebble check.
+
+    For example 'http-check' (from JUJU_PEBBLE_CHECK_NAME).
+    """
+
+    relation_departing_unit_name: Optional[str] = None
+    """The unit that is departing a relation.
+
+    For example 'remote/42' (from JUJU_DEPARTING_UNIT).
+    """
+
+    relation_name: Optional[str] = None
+    """The name of the relation.
+
+    For example 'database' (from JUJU_RELATION).
+    """
+
+    relation_id: Optional[int] = None
+    """The id of the relation.
+
+    For example 1 (integer) if the original environment variable's value is 'database:1'
+    (from JUJU_RELATION_ID).
+    """
+
+    remote_app_name: Optional[str] = None
+    """The name of the remote app.
+
+    For example 'remoteapp1' (from JUJU_REMOTE_APP).
+    """
+
+    remote_unit_name: Optional[str] = None
+    """The name of the remote unit.
+
+    For example 'remoteapp1/0' (from JUJU_REMOTE_UNIT).
+    """
+
+    secret_id: Optional[str] = None
+    """The ID of the secret.
+
+    For example 'secret:dcc7aa9c-7202-4da6-8d5f-0fbbaa4e1a41' (from JUJU_SECRET_ID).
+    """
+
+    secret_label: Optional[str] = None
+    """The label of the secret.
+
+    For example 'db-password' (from JUJU_SECRET_LABEL).
+    """
+
+    secret_revision: Optional[int] = None
+    """The revision of the secret.
+
+    For example 42 (integer) (from JUJU_SECRET_REVISION).
+    """
+
+    storage_name: Optional[str] = None
+    """The storage name.
+
+    For example 'my-storage' if the original environment variable's value is 'my-storage/1'
+    (from JUJU_STORAGE_ID).
+    """
+
+    unit_name: str = ''
+    """The name of the unit.
+
+    For example 'myapp/0' (from JUJU_UNIT_NAME).
+    """
+
+    version: JujuVersion = dataclasses.field(default_factory=lambda: JujuVersion('0.0.0'))
+    """The version of Juju.
+
+    For example '3.4.0' (from JUJU_VERSION).
+    """
+
+    workload_name: Optional[str] = None
+    """The name of the workload.
+
+    For example 'workload' (from JUJU_WORKLOAD_NAME).
+    """
+
+    @classmethod
+    def from_dict(cls, env: Mapping[str, Any]) -> '_JujuContext':
+        return _JujuContext(
+            action_name=env.get('JUJU_ACTION_NAME') or None,
+            action_uuid=env.get('JUJU_ACTION_UUID') or None,
+            charm_dir=(
+                Path(env['JUJU_CHARM_DIR']).resolve()
+                if env.get('JUJU_CHARM_DIR')
+                else Path(f'{__file__}/../../..').resolve()
+            ),
+            debug='JUJU_DEBUG' in env,
+            debug_at=(
+                {x.strip() for x in env['JUJU_DEBUG_AT'].split(',')}
+                if env.get('JUJU_DEBUG_AT')
+                else set()
+            ),
+            dispatch_path=env.get('JUJU_DISPATCH_PATH', ''),
+            model_name=env.get('JUJU_MODEL_NAME', ''),
+            model_uuid=env.get('JUJU_MODEL_UUID', ''),
+            notice_id=env.get('JUJU_NOTICE_ID') or None,
+            notice_key=env.get('JUJU_NOTICE_KEY') or None,
+            notice_type=env.get('JUJU_NOTICE_TYPE') or None,
+            pebble_check_name=env.get('JUJU_PEBBLE_CHECK_NAME') or None,
+            relation_departing_unit_name=env.get('JUJU_DEPARTING_UNIT') or None,
+            relation_name=env.get('JUJU_RELATION') or None,
+            relation_id=(
+                int(env['JUJU_RELATION_ID'].split(':')[-1])
+                if env.get('JUJU_RELATION_ID')
+                else None
+            ),
+            remote_app_name=env.get('JUJU_REMOTE_APP') or None,
+            remote_unit_name=env.get('JUJU_REMOTE_UNIT') or None,
+            secret_id=env.get('JUJU_SECRET_ID') or None,
+            secret_label=env.get('JUJU_SECRET_LABEL') or None,
+            secret_revision=(
+                int(env['JUJU_SECRET_REVISION']) if env.get('JUJU_SECRET_REVISION') else None
+            ),
+            storage_name=(
+                env.get('JUJU_STORAGE_ID', '').split('/')[0]
+                if env.get('JUJU_STORAGE_ID')
+                else None
+            ),
+            unit_name=env.get('JUJU_UNIT_NAME', ''),
+            version=JujuVersion(env['JUJU_VERSION']),
+            workload_name=env.get('JUJU_WORKLOAD_NAME') or None,
+        )
diff --git a/ops/main.py b/ops/main.py
index dcb3f25..94105cb 100644
--- a/ops/main.py
+++ b/ops/main.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -33,7 +33,7 @@ import ops.framework
 import ops.model
 import ops.storage
 from ops.charm import CharmMeta
-from ops.jujuversion import JujuVersion
+from ops.jujucontext import _JujuContext
 from ops.log import setup_root_logging
 
 CHARM_STATE_FILE = '.unit-state.db'
@@ -53,16 +53,6 @@ def _exe_path(path: Path) -> Optional[Path]:
     return Path(p)
 
 
-def _get_charm_dir():
-    charm_dir = os.environ.get('JUJU_CHARM_DIR')
-    if charm_dir is None:
-        # Assume $JUJU_CHARM_DIR/lib/op/main.py structure.
-        charm_dir = Path(f'{__file__}/../../..').resolve()
-    else:
-        charm_dir = Path(charm_dir).resolve()
-    return charm_dir
-
-
 def _create_event_link(
     charm: 'ops.charm.CharmBase',
     bound_event: 'ops.framework.EventSource',
@@ -104,7 +94,7 @@ def _create_event_link(
         event_path.symlink_to(target_path)
 
 
-def _setup_event_links(charm_dir: Path, charm: 'ops.charm.CharmBase'):
+def _setup_event_links(charm_dir: Path, charm: 'ops.charm.CharmBase', juju_context: _JujuContext):
     """Set up links for supported events that originate from Juju.
 
     Whether a charm can handle an event or not can be determined by
@@ -117,21 +107,23 @@ def _setup_event_links(charm_dir: Path, charm: 'ops.charm.CharmBase'):
     Args:
         charm_dir: A root directory of the charm.
         charm: An instance of the Charm class.
+        juju_context: An instance of the _JujuContext class.
 
     """
-    link_to = os.path.realpath(os.environ.get('JUJU_DISPATCH_PATH', sys.argv[0]))
+    link_to = os.path.realpath(juju_context.dispatch_path or sys.argv[0])
     for bound_event in charm.on.events().values():
         # Only events that originate from Juju need symlinks.
         if issubclass(bound_event.event_type, (ops.charm.HookEvent, ops.charm.ActionEvent)):
             _create_event_link(charm, bound_event, link_to)
 
 
-def _emit_charm_event(charm: 'ops.charm.CharmBase', event_name: str):
+def _emit_charm_event(charm: 'ops.charm.CharmBase', event_name: str, juju_context: _JujuContext):
     """Emits a charm event based on a Juju event name.
 
     Args:
         charm: A charm instance to emit an event from.
         event_name: A Juju event name to emit on a charm.
+        juju_context: An instance of the _JujuContext class.
     """
     event_to_emit = None
     try:
@@ -142,51 +134,48 @@ def _emit_charm_event(charm: 'ops.charm.CharmBase', event_name: str):
     # If the event is not supported by the charm implementation, do
     # not error out or try to emit it. This is to support rollbacks.
     if event_to_emit is not None:
-        args, kwargs = _get_event_args(charm, event_to_emit)
+        args, kwargs = _get_event_args(charm, event_to_emit, juju_context)
         logger.debug('Emitting Juju event %s.', event_name)
         event_to_emit.emit(*args, **kwargs)
 
 
-def _get_juju_relation_id():
-    return int(os.environ['JUJU_RELATION_ID'].split(':')[-1])
-
-
 def _get_event_args(
-    charm: 'ops.charm.CharmBase', bound_event: 'ops.framework.BoundEvent'
+    charm: 'ops.charm.CharmBase',
+    bound_event: 'ops.framework.BoundEvent',
+    juju_context: _JujuContext,
 ) -> Tuple[List[Any], Dict[str, Any]]:
     event_type = bound_event.event_type
     model = charm.framework.model
 
     relation = None
     if issubclass(event_type, ops.charm.WorkloadEvent):
-        workload_name = os.environ['JUJU_WORKLOAD_NAME']
+        workload_name = juju_context.workload_name
+        assert workload_name is not None
         container = model.unit.get_container(workload_name)
         args: List[Any] = [container]
         if issubclass(event_type, ops.charm.PebbleNoticeEvent):
-            notice_id = os.environ['JUJU_NOTICE_ID']
-            notice_type = os.environ['JUJU_NOTICE_TYPE']
-            notice_key = os.environ['JUJU_NOTICE_KEY']
+            notice_id = juju_context.notice_id
+            notice_type = juju_context.notice_type
+            notice_key = juju_context.notice_key
             args.extend([notice_id, notice_type, notice_key])
         elif issubclass(event_type, ops.charm.PebbleCheckEvent):
-            check_name = os.environ['JUJU_PEBBLE_CHECK_NAME']
+            check_name = juju_context.pebble_check_name
             args.append(check_name)
         return args, {}
     elif issubclass(event_type, ops.charm.SecretEvent):
         args: List[Any] = [
-            os.environ['JUJU_SECRET_ID'],
-            os.environ.get('JUJU_SECRET_LABEL'),
+            juju_context.secret_id,
+            juju_context.secret_label,
         ]
         if issubclass(event_type, (ops.charm.SecretRemoveEvent, ops.charm.SecretExpiredEvent)):
-            args.append(int(os.environ['JUJU_SECRET_REVISION']))
+            args.append(juju_context.secret_revision)
         return args, {}
     elif issubclass(event_type, ops.charm.StorageEvent):
-        storage_id = os.environ.get('JUJU_STORAGE_ID', '')
-        if storage_id:
-            storage_name = storage_id.split('/')[0]
-        else:
-            # Before JUJU_STORAGE_ID exists, take the event name as
-            # <storage_name>_storage_<attached|detached> and replace it with <storage_name>
-            storage_name = '-'.join(bound_event.event_kind.split('_')[:-2])
+        # Before JUJU_STORAGE_ID exists, take the event name as
+        # <storage_name>_storage_<attached|detached> and replace it with <storage_name>
+        storage_name = juju_context.storage_name or '-'.join(
+            bound_event.event_kind.split('_')[:-2]
+        )
 
         storages = model.storages[storage_name]
         index, storage_location = model._backend._storage_event_details()
@@ -199,16 +188,17 @@ def _get_event_args(
         storage.location = storage_location  # type: ignore
         return [storage], {}
     elif issubclass(event_type, ops.charm.ActionEvent):
-        args: List[Any] = [os.environ['JUJU_ACTION_UUID']]
+        args: List[Any] = [juju_context.action_uuid]
         return args, {}
     elif issubclass(event_type, ops.charm.RelationEvent):
-        relation_name = os.environ['JUJU_RELATION']
-        relation_id = _get_juju_relation_id()
+        relation_name = juju_context.relation_name
+        assert relation_name is not None
+        relation_id = juju_context.relation_id
         relation: Optional[ops.model.Relation] = model.get_relation(relation_name, relation_id)
 
-    remote_app_name = os.environ.get('JUJU_REMOTE_APP', '')
-    remote_unit_name = os.environ.get('JUJU_REMOTE_UNIT', '')
-    departing_unit_name = os.environ.get('JUJU_DEPARTING_UNIT', '')
+    remote_app_name = juju_context.remote_app_name
+    remote_unit_name = juju_context.remote_unit_name
+    departing_unit_name = juju_context.relation_departing_unit_name
 
     if not remote_app_name and remote_unit_name:
         if '/' not in remote_unit_name:
@@ -244,12 +234,13 @@ class _Dispatcher:
 
     """
 
-    def __init__(self, charm_dir: Path):
+    def __init__(self, charm_dir: Path, juju_context: _JujuContext):
+        self._juju_context = juju_context
         self._charm_dir = charm_dir
-        self._exec_path = Path(os.environ.get('JUJU_DISPATCH_PATH', sys.argv[0]))
+        self._exec_path = Path(self._juju_context.dispatch_path or sys.argv[0])
 
         dispatch = charm_dir / 'dispatch'
-        if JujuVersion.from_environ().is_dispatch_aware() and _exe_path(dispatch) is not None:
+        if self._juju_context.version.is_dispatch_aware() and _exe_path(dispatch) is not None:
             self._init_dispatch()
         else:
             self._init_legacy()
@@ -270,7 +261,7 @@ class _Dispatcher:
         if self.event_name in ('install', 'start', 'upgrade_charm') or self.event_name.endswith(
             '_storage_attached'
         ):
-            _setup_event_links(self._charm_dir, charm)
+            _setup_event_links(self._charm_dir, charm, self._juju_context)
 
     def run_any_legacy_hook(self):
         """Run any extant legacy hook.
@@ -334,7 +325,7 @@ class _Dispatcher:
         JUJU_DISPATCH_PATH will be set to the wanted hook, e.g. hooks/install,
         in both cases.
         """
-        self._dispatch_path = Path(os.environ['JUJU_DISPATCH_PATH'])
+        self._dispatch_path = Path(self._juju_context.dispatch_path)
 
         if 'OPERATOR_DISPATCH' in os.environ:
             logger.debug('Charm called itself via %s.', self._dispatch_path)
@@ -354,7 +345,9 @@ class _Dispatcher:
         return self.event_name in ('collect_metrics',)
 
 
-def _should_use_controller_storage(db_path: Path, meta: CharmMeta) -> bool:
+def _should_use_controller_storage(
+    db_path: Path, meta: CharmMeta, juju_context: _JujuContext
+) -> bool:
     """Figure out whether we want to use controller storage or not."""
     # if local state has been used previously, carry on using that
     if db_path.exists():
@@ -367,13 +360,11 @@ def _should_use_controller_storage(db_path: Path, meta: CharmMeta) -> bool:
         return False
 
     # are we in a new enough Juju?
-    cur_version = JujuVersion.from_environ()
-
-    if cur_version.has_controller_storage():
-        logger.debug('Using controller storage: JUJU_VERSION=%s', cur_version)
+    if juju_context.version.has_controller_storage():
+        logger.debug('Using controller storage: JUJU_VERSION=%s', juju_context.version)
         return True
     else:
-        logger.debug('Using local storage: JUJU_VERSION=%s', cur_version)
+        logger.debug('Using local storage: JUJU_VERSION=%s', juju_context.version)
         return False
 
 
@@ -411,21 +402,22 @@ class _Manager:
         use_juju_for_storage: Optional[bool] = None,
         charm_state_path: str = CHARM_STATE_FILE,
     ):
+        self._juju_context = _JujuContext.from_dict(os.environ)
         self._charm_state_path = charm_state_path
         self._charm_class = charm_class
         if model_backend is None:
-            model_backend = ops.model._ModelBackend()
+            model_backend = ops.model._ModelBackend(juju_context=self._juju_context)
         self._model_backend = model_backend
 
         # Do this as early as possible to be sure to catch the most logs.
         self._setup_root_logging()
 
-        self._charm_root = _get_charm_dir()
+        self._charm_root = self._juju_context.charm_dir
         self._charm_meta = CharmMeta.from_charm_root(self._charm_root)
         self._use_juju_for_storage = use_juju_for_storage
 
         # Set up dispatcher, framework and charm objects.
-        self.dispatcher = _Dispatcher(self._charm_root)
+        self.dispatcher = _Dispatcher(self._charm_root, self._juju_context)
         self.dispatcher.run_any_legacy_hook()
 
         self.framework = self._make_framework(self.dispatcher)
@@ -437,12 +429,13 @@ class _Manager:
         return charm
 
     def _setup_root_logging(self):
-        debug = 'JUJU_DEBUG' in os.environ
         # For actions, there is a communication channel with the user running the
         # action, so we want to send exception details through stderr, rather than
         # only to juju-log as normal.
-        handling_action = 'JUJU_ACTION_NAME' in os.environ
-        setup_root_logging(self._model_backend, debug=debug, exc_stderr=handling_action)
+        handling_action = self._juju_context.action_name is not None
+        setup_root_logging(
+            self._model_backend, debug=self._juju_context.debug, exc_stderr=handling_action
+        )
 
         logger.debug('ops %s up and running.', ops.__version__)  # type:ignore
 
@@ -453,11 +446,11 @@ class _Manager:
         if use_juju_for_storage and not ops.storage.juju_backend_available():
             # raise an exception; the charm is broken and needs fixing.
             msg = 'charm set use_juju_for_storage=True, but Juju version {} does not support it'
-            raise RuntimeError(msg.format(JujuVersion.from_environ()))
+            raise RuntimeError(msg.format(self._juju_context.version))
 
         if use_juju_for_storage is None:
             use_juju_for_storage = _should_use_controller_storage(
-                charm_state_path, self._charm_meta
+                charm_state_path, self._charm_meta, self._juju_context
             )
         elif use_juju_for_storage:
             warnings.warn(
@@ -488,8 +481,9 @@ class _Manager:
     def _make_framework(self, dispatcher: _Dispatcher):
         # If we are in a RelationBroken event, we want to know which relation is
         # broken within the model, not only in the event's `.relation` attribute.
-        if os.environ.get('JUJU_DISPATCH_PATH', '').endswith('-relation-broken'):
-            broken_relation_id = _get_juju_relation_id()
+
+        if self._juju_context.dispatch_path.endswith('-relation-broken'):
+            broken_relation_id = self._juju_context.relation_id
         else:
             broken_relation_id = None
 
@@ -498,7 +492,12 @@ class _Manager:
         )
         store = self._make_storage(dispatcher)
         framework = ops.framework.Framework(
-            store, self._charm_root, self._charm_meta, model, event_name=dispatcher.event_name
+            store,
+            self._charm_root,
+            self._charm_meta,
+            model,
+            event_name=dispatcher.event_name,
+            juju_debug_at=self._juju_context.debug_at,
         )
         framework.set_breakpointhook()
         return framework
@@ -516,7 +515,7 @@ class _Manager:
             self.framework.reemit()
 
         # Emit the Juju event.
-        _emit_charm_event(self.charm, self.dispatcher.event_name)
+        _emit_charm_event(self.charm, self.dispatcher.event_name, self._juju_context)
         # Emit collect-status events.
         ops.charm._evaluate_status(self.charm)
 
diff --git a/ops/model.py b/ops/model.py
index b5cc355..3b9c4d8 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2021 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -55,8 +55,13 @@ from typing import (
 import ops
 import ops.pebble as pebble
 from ops._private import timeconv, yaml
+from ops.jujucontext import _JujuContext
 from ops.jujuversion import JujuVersion
 
+# JujuVersion is not used in ops.model, but there are charms that are importing JujuVersion
+# from ops.model, so we keep it here.
+_ = JujuVersion
+
 # a k8s spec is a mapping from names/"types" to json/yaml spec objects
 K8sSpec = Mapping[str, Any]
 
@@ -2220,6 +2225,8 @@ class Container:
     ):
         self.name = name
 
+        self._juju_version = backend._juju_context.version
+
         if pebble_client is None:
             socket_path = f'/charm/containers/{name}/pebble.socket'
             pebble_client = backend.get_pebble(socket_path)
@@ -2864,19 +2871,18 @@ class Container:
         See :meth:`ops.pebble.Client.exec` for documentation of the parameters
         and return value, as well as examples.
 
-        Note that older versions of Juju do not support the ``service_content`` parameter, so if
+        Note that older versions of Juju do not support the ``service_context`` parameter, so if
         the Charm is to be used on those versions, then
         :meth:`JujuVersion.supports_exec_service_context` should be used as a guard.
 
         Raises:
             ExecError: if the command exits with a non-zero exit code.
         """
-        if service_context is not None:
-            version = JujuVersion.from_environ()
-            if not version.supports_exec_service_context:
-                raise RuntimeError(
-                    f'exec with service_context not supported on Juju version {version}'
-                )
+        assert self._juju_version is not None
+        if service_context is not None and not self._juju_version.supports_exec_service_context:
+            raise RuntimeError(
+                f'exec with service_context not supported on Juju version {self._juju_version}'
+            )
         return self._pebble.exec(
             command,
             service_context=service_context,
@@ -3156,16 +3162,20 @@ class _ModelBackend:
         unit_name: Optional[str] = None,
         model_name: Optional[str] = None,
         model_uuid: Optional[str] = None,
+        juju_context: Optional[_JujuContext] = None,
     ):
+        if juju_context is None:
+            juju_context = _JujuContext.from_dict(os.environ)
+        self._juju_context = juju_context
         # if JUJU_UNIT_NAME is not being passed nor in the env, something is wrong
-        unit_name_ = unit_name or os.getenv('JUJU_UNIT_NAME')
+        unit_name_ = unit_name or self._juju_context.unit_name
         if unit_name_ is None:
             raise ValueError('JUJU_UNIT_NAME not set')
         self.unit_name: str = unit_name_
 
         # we can cast to str because these envvars are guaranteed to be set
-        self.model_name: str = model_name or typing.cast(str, os.getenv('JUJU_MODEL_NAME'))
-        self.model_uuid: str = model_uuid or typing.cast(str, os.getenv('JUJU_MODEL_UUID'))
+        self.model_name: str = model_name or self._juju_context.model_name
+        self.model_uuid: str = model_uuid or self._juju_context.model_uuid
         self.app_name: str = self.unit_name.split('/')[0]
 
         self._is_leader: Optional[bool] = None
@@ -3231,11 +3241,14 @@ class _ModelBackend:
 
     def relation_remote_app_name(self, relation_id: int) -> Optional[str]:
         """Return remote app name for given relation ID, or None if not known."""
-        if 'JUJU_RELATION_ID' in os.environ and 'JUJU_REMOTE_APP' in os.environ:
-            event_relation_id = int(os.environ['JUJU_RELATION_ID'].split(':')[-1])
+        if (
+            self._juju_context.relation_id is not None
+            and self._juju_context.remote_app_name is not None
+        ):
+            event_relation_id = self._juju_context.relation_id
             if relation_id == event_relation_id:
                 # JUJU_RELATION_ID is this relation, use JUJU_REMOTE_APP.
-                return os.getenv('JUJU_REMOTE_APP') or None
+                return self._juju_context.remote_app_name
 
         # If caller is asking for information about another relation, use
         # "relation-list --app" to get it.
@@ -3261,12 +3274,11 @@ class _ModelBackend:
         if not isinstance(is_app, bool):
             raise TypeError('is_app parameter to relation_get must be a boolean')
 
-        if is_app:
-            version = JujuVersion.from_environ()
-            if not version.has_app_data():
-                raise RuntimeError(
-                    f'getting application data is not supported on Juju version {version}'
-                )
+        if is_app and not self._juju_context.version.has_app_data():
+            raise RuntimeError(
+                'getting application data is not supported on Juju version '
+                f'{self._juju_context.version}'
+            )
 
         args = ['relation-get', '-r', str(relation_id), '-', member_name]
         if is_app:
@@ -3284,12 +3296,11 @@ class _ModelBackend:
         if not isinstance(is_app, bool):
             raise TypeError('is_app parameter to relation_set must be a boolean')
 
-        if is_app:
-            version = JujuVersion.from_environ()
-            if not version.has_app_data():
-                raise RuntimeError(
-                    f'setting application data is not supported on Juju version {version}'
-                )
+        if is_app and not self._juju_context.version.has_app_data():
+            raise RuntimeError(
+                'setting application data is not supported on Juju version '
+                f'{self._juju_context.version}'
+            )
 
         args = ['relation-set', '-r', str(relation_id)]
         if is_app:
diff --git a/ops/storage.py b/ops/storage.py
index ccbebd0..5024e53 100644
--- a/ops/storage.py
+++ b/ops/storage.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/pyproject.toml b/pyproject.toml
index c897bf6..e6f21a6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,7 +28,7 @@ docs = [
     "linkify-it-py",
     "myst-parser",
     "pyspelling",
-    "sphinx==6.2.1",
+    "sphinx ~= 8.0.0",
     "sphinx-autobuild",
     "sphinx-copybutton",
     "sphinx-design",
@@ -160,6 +160,7 @@ ignore = [
     # Unnecessary `tuple` call (rewrite as a literal)
     "C408",
 ]
+
 [tool.ruff.lint.per-file-ignores]
 "test/*" = [
     # All documentation linting.
@@ -181,6 +182,16 @@ ignore = [
     "S605",  # Starting a process with a shell: seems safe, but may be changed in the future; consider rewriting without `shell`
     "S607",  # Starting a process with a partial executable path
 ]
+"docs/custom_conf.py" = [
+    "D100",  # Missing docstring in public module
+    "D101",  # Missing docstring in public class
+    "D102",  # Missing docstring in public method
+    "D103",  # Missing docstring in public function
+    "E266",  # Too many leading `#` before block comment
+    "I001",  # Import block is un-sorted or un-formatted
+    "RUF003",  # Comment contains ambiguous unicode characters (EN DASH, RIGHT SINGLE QUOTATION MARK)
+    "UP031",  # Use format specifiers instead of percent format
+]
 
 [tool.ruff.lint.pydocstyle]
 convention = "google"
diff --git a/tox.ini b/tox.ini
index ecadde0..68acc7e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,17 @@
 # Copyright 2021 Canonical Ltd.
-# See LICENSE file for licensing details.
+#
+# 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.
+
 [tox]
 skipsdist=True
 skip_missing_interpreters = True


Test patch:
diff --git a/ops/testing.py b/ops/testing.py
index bf34016..72c64e1 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -59,6 +59,7 @@ from typing import (
 from ops import charm, framework, model, pebble, storage
 from ops._private import yaml
 from ops.charm import CharmBase, CharmMeta, RelationRole
+from ops.jujucontext import _JujuContext
 from ops.model import Container, RelationNotFoundError, _NetworkDict
 from ops.pebble import ExecProcess
 
@@ -259,6 +260,9 @@ class Harness(Generic[CharmType]):
         actions: Optional[YAMLStringOrFile] = None,
         config: Optional[YAMLStringOrFile] = None,
     ):
+        if 'JUJU_VERSION' not in os.environ:
+            os.environ['JUJU_VERSION'] = '0.0.0'
+        self._juju_context = _JujuContext.from_dict(os.environ)
         self._charm_cls = charm_cls
         self._charm: Optional[CharmType] = None
         self._charm_dir = 'no-disk-path'  # this may be updated by _create_meta
@@ -268,11 +272,17 @@ class Harness(Generic[CharmType]):
         self._relation_id_counter: int = 0
         self._action_id_counter: int = 0
         config_ = self._get_config(config)
-        self._backend = _TestingModelBackend(self._unit_name, self._meta, config_)
+        self._backend = _TestingModelBackend(
+            self._unit_name, self._meta, config_, self._juju_context
+        )
         self._model = model.Model(self._meta, self._backend)
         self._storage = storage.SQLiteStorage(':memory:')
         self._framework = framework.Framework(
-            self._storage, self._charm_dir, self._meta, self._model
+            self._storage,
+            self._charm_dir,
+            self._meta,
+            self._model,
+            juju_debug_at=self._juju_context.debug_at,
         )
 
     def _event_context(self, event_name: str):
@@ -2292,7 +2302,14 @@ class _TestingModelBackend:
     as the only public methods of this type are for implementing ModelBackend.
     """
 
-    def __init__(self, unit_name: str, meta: charm.CharmMeta, config: '_RawConfig'):
+    def __init__(
+        self,
+        unit_name: str,
+        meta: charm.CharmMeta,
+        config: '_RawConfig',
+        juju_context: _JujuContext,
+    ):
+        self._juju_context = juju_context
         self.unit_name = unit_name
         self.app_name = self.unit_name.split('/')[0]
         self.model_name = None
diff --git a/test/__init__.py b/test/__init__.py
index 6fb08cc..846ff8c 100644
--- a/test/__init__.py
+++ b/test/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2024 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/test/charms/test_main/actions.yaml b/test/charms/test_main/actions.yaml
index 1eca1b7..fa4d3cc 100644
--- a/test/charms/test_main/actions.yaml
+++ b/test/charms/test_main/actions.yaml
@@ -1,3 +1,17 @@
+# Copyright 2019 Canonical Ltd.
+#
+# 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.
+
 foo-bar:
   description: Foos the bar.
   title: foo-bar
diff --git a/test/charms/test_main/config.yaml b/test/charms/test_main/config.yaml
index ffc0186..26e3ec3 100644
--- a/test/charms/test_main/config.yaml
+++ b/test/charms/test_main/config.yaml
@@ -1,1 +1,15 @@
+# Copyright 2019 Canonical Ltd.
+#
+# 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.
+
 "options": {}
diff --git a/test/charms/test_main/lib/__init__.py b/test/charms/test_main/lib/__init__.py
index 6fb08cc..846ff8c 100644
--- a/test/charms/test_main/lib/__init__.py
+++ b/test/charms/test_main/lib/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2024 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/test/charms/test_main/metadata.yaml b/test/charms/test_main/metadata.yaml
index a3bda3c..7deebc6 100644
--- a/test/charms/test_main/metadata.yaml
+++ b/test/charms/test_main/metadata.yaml
@@ -1,3 +1,17 @@
+# Copyright 2019 Canonical Ltd.
+#
+# 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.
+
 name: main
 summary: A charm used for testing the basic operation of the entrypoint code.
 maintainer: Dmitrii Shcherbakov <dmitrii.shcherbakov@canonical.com>
diff --git a/test/charms/test_smoke/metadata.yaml b/test/charms/test_smoke/metadata.yaml
index 917eaf0..b07ff11 100644
--- a/test/charms/test_smoke/metadata.yaml
+++ b/test/charms/test_smoke/metadata.yaml
@@ -1,5 +1,16 @@
-# Copyright 2022 Penelope Valentine Gale
-# See LICENSE file for licensing details.
+# Copyright 2022 Canonical Ltd.
+#
+# 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.
 
 # For a complete list of supported options, see:
 # https://juju.is/docs/sdk/metadata-reference
diff --git a/test/test_charm.py b/test/test_charm.py
index 0b825a6..b6879b1 100644
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/test/test_framework.py b/test/test_framework.py
index 7be174a..a3f5924 100644
--- a/test/test_framework.py
+++ b/test/test_framework.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -30,6 +30,7 @@ import pytest
 
 import ops
 from ops.framework import _BREAKPOINT_WELCOME_MESSAGE, _event_regex
+from ops.jujucontext import _JujuContext
 from ops.model import _ModelBackend
 from ops.storage import JujuStorage, NoSnapshotError, SQLiteStorage
 from test.test_helpers import FakeScript
@@ -37,6 +38,8 @@ from test.test_helpers import FakeScript
 
 def create_model():
     """Create a Model object."""
+    if 'JUJU_VERSION' not in os.environ:
+        os.environ['JUJU_VERSION'] = '0.0.0'
     backend = _ModelBackend(unit_name='myapp/0')
     meta = ops.CharmMeta()
     model = ops.Model(meta, backend)
@@ -64,11 +67,13 @@ def create_framework(
 
     patcher = patch('ops.storage.SQLiteStorage.DB_LOCK_TIMEOUT', datetime.timedelta(0))
     patcher.start()
+    os.environ.update({'JUJU_VERSION': '0.0.0'})
     framework = ops.Framework(
         SQLiteStorage(data_fpath),
         charm_dir,
         meta=model._cache._meta if model else ops.CharmMeta(),
         model=model,  # type: ignore
+        juju_debug_at=_JujuContext.from_dict(os.environ).debug_at,
     )
     request.addfinalizer(framework.close)
     request.addfinalizer(patcher.stop)
@@ -84,7 +89,13 @@ class TestFramework:
     def test_deprecated_init(self, caplog: pytest.LogCaptureFixture):
         # For 0.7, this still works, but it is deprecated.
         with caplog.at_level(logging.WARNING):
-            framework = ops.Framework(':memory:', None, None, None)  # type: ignore
+            framework = ops.Framework(
+                ':memory:',  # type: ignore
+                None,  # type: ignore
+                None,  # type: ignore
+                None,  # type: ignore
+                juju_debug_at=set(),
+            )
         assert 'WARNING:ops.framework:deprecated: Framework now takes a Storage not a path' in [
             f'{record.levelname}:{record.name}:{record.message}' for record in caplog.records
         ]
@@ -1489,7 +1500,14 @@ class TestStoredState:
                 model: ops.Model,
                 event_name: str,
             ):
-                super().__init__(store, charm_dir, meta, model, event_name)
+                super().__init__(
+                    store,
+                    charm_dir,
+                    meta,
+                    model,
+                    event_name,
+                    juju_debug_at=set(),
+                )
                 self.snapshots: typing.List[typing.Any] = []
 
             def save_snapshot(self, value: typing.Union[ops.StoredStateData, ops.EventBase]):
diff --git a/test/test_helpers.py b/test/test_helpers.py
index 84f9b20..2bd2a9d 100644
--- a/test/test_helpers.py
+++ b/test/test_helpers.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2021 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@ import unittest
 import pytest
 
 import ops
+from ops.jujucontext import _JujuContext
 from ops.model import _ModelBackend
 from ops.storage import SQLiteStorage
 
@@ -88,6 +89,7 @@ def create_framework(
         os.environ['PATH'],
     ])
     os.environ['JUJU_UNIT_NAME'] = 'local/0'
+    os.environ['JUJU_VERSION'] = '0.0.0'
 
     tmpdir = pathlib.Path(tempfile.mkdtemp())
 
@@ -105,7 +107,13 @@ def create_framework(
         meta = ops.CharmMeta()
     model = ops.Model(meta, _ModelBackend('local/0'))  # type: ignore
     # We can pass foo_event as event_name because we're not actually testing dispatch.
-    framework = ops.Framework(SQLiteStorage(':memory:'), tmpdir, meta, model)  # type: ignore
+    framework = ops.Framework(
+        SQLiteStorage(':memory:'),
+        tmpdir,
+        meta,
+        model,
+        juju_debug_at=_JujuContext.from_dict(os.environ).debug_at,
+    )
 
     def finalizer():
         os.environ.clear()
diff --git a/test/test_infra.py b/test/test_infra.py
index 6efab18..445bbce 100644
--- a/test/test_infra.py
+++ b/test/test_infra.py
@@ -1,4 +1,4 @@
-# Copyright 2020-2021 Canonical Ltd.
+# Copyright 2020 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/test/test_jujucontext.py b/test/test_jujucontext.py
new file mode 100644
index 0000000..147dcc1
--- /dev/null
+++ b/test/test_jujucontext.py
@@ -0,0 +1,117 @@
+# Copyright 2024 Canonical Ltd.
+#
+# 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.
+
+from pathlib import Path
+
+from ops.jujucontext import _JujuContext
+from ops.jujuversion import JujuVersion
+
+
+class TestJujuContext:
+    def test_both_str_and_int_fields_default_to_none(self):
+        juju_context = _JujuContext.from_dict({'JUJU_VERSION': '0.0.0'})
+        assert juju_context.action_name is None
+        assert juju_context.relation_id is None
+
+    def test_parsing_int_fields(self):
+        juju_context = _JujuContext.from_dict({
+            'JUJU_VERSION': '0.0.0',
+            'JUJU_RELATION_ID': 'x:42',
+        })
+        assert juju_context.relation_id == 42
+
+    def test_parsing_secret_revision_as_int(self):
+        juju_context = _JujuContext.from_dict({
+            'JUJU_VERSION': '0.0.0',
+            'JUJU_SECRET_REVISION': '42',
+        })
+        assert juju_context.secret_revision == 42
+
+    def test_parsing_juju_debug_as_bool(self):
+        juju_context = _JujuContext.from_dict({
+            'JUJU_VERSION': '0.0.0',
+            'JUJU_DEBUG': 'true',
+        })
+        assert juju_context.debug is True
+
+    def test_parsing_juju_debug_at_as_set(self):
+        juju_context = _JujuContext.from_dict({
+            'JUJU_VERSION': '0.0.0',
+            'JUJU_DEBUG_AT': 'all,hook',
+        })
+        assert juju_context.debug_at == set(('all', 'hook'))
+
+    def test_parsing_juju_charm_dir(self):
+        juju_context = _JujuContext.from_dict({
+            'JUJU_VERSION': '0.0.0',
+            'JUJU_CHARM_DIR': '/dir',
+        })
+        assert juju_context.charm_dir == Path('/dir')
+
+    def test_parsing_juju_charm_dir_not_set(self):
+        juju_context = _JujuContext.from_dict({'JUJU_VERSION': '0.0.0'})
+        assert juju_context.charm_dir == Path(f'{__file__}/../../..').resolve()
+
+    def test_parsing_juju_version(self):
+        juju_context = _JujuContext.from_dict({'JUJU_VERSION': '3.4.0'})
+        assert juju_context.version == JujuVersion('3.4.0')
+
+    def test_parsing_storage_id_to_name(self):
+        juju_context = _JujuContext.from_dict({
+            'JUJU_VERSION': '0.0.0',
+            'JUJU_STORAGE_ID': 'my-storage/1',
+        })
+        assert juju_context.storage_name == 'my-storage'
+
+    def test_parsing_all_str_fields(self):
+        env = {
+            'JUJU_ACTION_NAME': 'backup',
+            'JUJU_ACTION_UUID': '1',
+            'JUJU_DISPATCH_PATH': 'actions/do-something',
+            'JUJU_MODEL_NAME': 'foo',
+            'JUJU_MODEL_UUID': 'cdac5656-2423-4388-8f30-41854b4cca7d',
+            'JUJU_NOTICE_ID': '1',
+            'JUJU_NOTICE_KEY': 'example.com/a',
+            'JUJU_NOTICE_TYPE': 'custom',
+            'JUJU_PEBBLE_CHECK_NAME': 'http-check',
+            'JUJU_DEPARTING_UNIT': 'remote/42',
+            'JUJU_RELATION': 'database',
+            'JUJU_REMOTE_APP': 'remoteapp1',
+            'JUJU_REMOTE_UNIT': 'remoteapp1/0',
+            'JUJU_SECRET_ID': 'secret:dcc7aa9c-7202-4da6-8d5f-0fbbaa4e1a41',
+            'JUJU_SECRET_LABEL': 'db-password',
+            'JUJU_UNIT_NAME': '42',
+            'JUJU_VERSION': '0.0.0',
+            'JUJU_WORKLOAD_NAME': 'workload',
+        }
+
+        juju_context = _JujuContext.from_dict(env)
+
+        assert juju_context.action_name == 'backup'
+        assert juju_context.action_uuid == '1'
+        assert juju_context.dispatch_path == 'actions/do-something'
+        assert juju_context.model_name == 'foo'
+        assert juju_context.model_uuid == 'cdac5656-2423-4388-8f30-41854b4cca7d'
+        assert juju_context.notice_id == '1'
+        assert juju_context.notice_key == 'example.com/a'
+        assert juju_context.notice_type == 'custom'
+        assert juju_context.pebble_check_name == 'http-check'
+        assert juju_context.relation_departing_unit_name == 'remote/42'
+        assert juju_context.relation_name == 'database'
+        assert juju_context.remote_app_name == 'remoteapp1'
+        assert juju_context.remote_unit_name == 'remoteapp1/0'
+        assert juju_context.secret_id == 'secret:dcc7aa9c-7202-4da6-8d5f-0fbbaa4e1a41'
+        assert juju_context.secret_label == 'db-password'
+        assert juju_context.unit_name == '42'
+        assert juju_context.workload_name == 'workload'
diff --git a/test/test_main.py b/test/test_main.py
index a4765b2..c962cdd 100644
--- a/test/test_main.py
+++ b/test/test_main.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2021 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -30,6 +30,7 @@ from unittest.mock import patch
 import pytest
 
 import ops
+from ops.jujucontext import _JujuContext
 from ops.main import _should_use_controller_storage
 from ops.storage import SQLiteStorage
 
@@ -138,16 +139,16 @@ class TestCharmInit:
         }
         if extra_environ is not None:
             fake_environ.update(extra_environ)
-        with patch.dict(os.environ, fake_environ):
-            with patch('ops.main._get_charm_dir') as mock_charmdir:
-                with tempfile.TemporaryDirectory() as tmpdirname:
-                    tmpdirname = Path(tmpdirname)
-                    fake_metadata = tmpdirname / 'metadata.yaml'
-                    with fake_metadata.open('wb') as fh:
-                        fh.write(b'name: test')
-                    mock_charmdir.return_value = tmpdirname
 
-                    ops.main(charm_class, **kwargs)  # type: ignore
+        with tempfile.TemporaryDirectory() as tmpdirname:
+            fake_environ.update({'JUJU_CHARM_DIR': tmpdirname})
+            with patch.dict(os.environ, fake_environ):
+                tmpdirname = Path(tmpdirname)
+                fake_metadata = tmpdirname / 'metadata.yaml'
+                with fake_metadata.open('wb') as fh:
+                    fh.write(b'name: test')
+
+                ops.main(charm_class, **kwargs)  # type: ignore
 
     def test_init_signature_passthrough(self):
         class MyCharm(ops.CharmBase):
@@ -223,6 +224,7 @@ class TestDispatch:
             fake_environ['JUJU_VERSION'] = '2.7.0'
 
         with tempfile.TemporaryDirectory() as tmpdir:
+            fake_environ.update({'JUJU_CHARM_DIR': tmpdir})
             tmpdir = Path(tmpdir)
             fake_metadata = tmpdir / 'metadata.yaml'
             with fake_metadata.open('wb') as fh:
@@ -234,9 +236,7 @@ class TestDispatch:
 
             with patch.dict(os.environ, fake_environ):
                 with patch('ops.main._emit_charm_event') as mock_charm_event:
-                    with patch('ops.main._get_charm_dir') as mock_charmdir:
-                        mock_charmdir.return_value = tmpdir
-                        ops.main(MyCharm)  # type: ignore
+                    ops.main(MyCharm)  # type: ignore
 
         assert mock_charm_event.call_count == 1
         return mock_charm_event.call_args[0][1]
@@ -358,8 +358,7 @@ class _TestMain(abc.ABC):
             self._setup_entry_point(actions_dir, action_name)
 
     def _read_and_clear_state(
-        self,
-        event_name: str,
+        self, event_name: str, env: typing.Dict[str, str]
     ) -> typing.Union[ops.BoundStoredState, ops.StoredStateData]:
         if self._charm_state_file.stat().st_size:
             storage = SQLiteStorage(self._charm_state_file)
@@ -370,7 +369,14 @@ class _TestMain(abc.ABC):
                         meta = ops.CharmMeta.from_yaml(m, a)
                 else:
                     meta = ops.CharmMeta.from_yaml(m)
-            framework = ops.Framework(storage, self.JUJU_CHARM_DIR, meta, None, event_name)  # type: ignore
+            framework = ops.Framework(
+                storage,
+                self.JUJU_CHARM_DIR,
+                meta,
+                None,  # type: ignore
+                event_name,
+                juju_debug_at=_JujuContext.from_dict(env).debug_at,
+            )
 
             class ThisCharmEvents(MyCharmEvents):
                 pass
@@ -474,7 +480,7 @@ class _TestMain(abc.ABC):
             env['JUJU_MODEL_NAME'] = event_spec.model_name
 
         self._call_event(fake_script, Path(event_dir, event_filename), env)
-        return self._read_and_clear_state(event_spec.event_name)
+        return self._read_and_clear_state(event_spec.event_name, env)
 
     @pytest.mark.usefixtures('setup_charm')
     def test_event_reemitted(self, fake_script: FakeScript):
@@ -1468,19 +1474,23 @@ class TestStorageHeuristics:
     def test_fallback_to_current_juju_version__too_old(self):
         meta = ops.CharmMeta.from_yaml('series: [kubernetes]')
         with patch.dict(os.environ, {'JUJU_VERSION': '1.0'}):
-            assert not _should_use_controller_storage(Path('/xyzzy'), meta)
+            juju_context = _JujuContext.from_dict(os.environ)
+            assert not _should_use_controller_storage(Path('/xyzzy'), meta, juju_context)
 
     def test_fallback_to_current_juju_version__new_enough(self):
         meta = ops.CharmMeta.from_yaml('series: [kubernetes]')
         with patch.dict(os.environ, {'JUJU_VERSION': '2.8'}):
-            assert _should_use_controller_storage(Path('/xyzzy'), meta)
+            juju_context = _JujuContext.from_dict(os.environ)
+            assert _should_use_controller_storage(Path('/xyzzy'), meta, juju_context)
 
     def test_not_if_not_in_k8s(self):
         meta = ops.CharmMeta.from_yaml('series: [ecs]')
         with patch.dict(os.environ, {'JUJU_VERSION': '2.8'}):
-            assert not _should_use_controller_storage(Path('/xyzzy'), meta)
+            juju_context = _JujuContext.from_dict(os.environ)
+            assert not _should_use_controller_storage(Path('/xyzzy'), meta, juju_context)
 
     def test_not_if_already_local(self):
         meta = ops.CharmMeta.from_yaml('series: [kubernetes]')
         with patch.dict(os.environ, {'JUJU_VERSION': '2.8'}), tempfile.NamedTemporaryFile() as fd:
-            assert not _should_use_controller_storage(Path(fd.name), meta)
+            juju_context = _JujuContext.from_dict(os.environ)
+            assert not _should_use_controller_storage(Path(fd.name), meta, juju_context)
diff --git a/test/test_model.py b/test/test_model.py
index e41ff03..5a5b362 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -32,6 +32,8 @@ import ops
 import ops.testing
 from ops import pebble
 from ops._private import yaml
+from ops.jujucontext import _JujuContext
+from ops.jujuversion import JujuVersion
 from ops.model import _ModelBackend
 from test.test_helpers import FakeScript
 
@@ -2075,8 +2077,8 @@ containers:
         assert len(caplog.records) == 1
         assert re.search(r'api error!', caplog.text)
 
-    @patch('model.JujuVersion.from_environ', new=lambda: ops.model.JujuVersion('3.1.6'))
-    def test_exec(self, container: ops.Container):
+    def test_exec(self, container: ops.Container, monkeypatch: pytest.MonkeyPatch):
+        monkeypatch.setattr(container, '_juju_version', JujuVersion('3.1.6'))
         container.pebble.responses.append('fake_exec_process')  # type: ignore
         stdout = io.StringIO('STDOUT')
         stderr = io.StringIO('STDERR')
@@ -2119,8 +2121,10 @@ containers:
         ]
         assert p == 'fake_exec_process'
 
-    @patch('model.JujuVersion.from_environ', new=lambda: ops.model.JujuVersion('3.1.5'))
-    def test_exec_service_context_not_supported(self, container: ops.Container):
+    def test_exec_service_context_not_supported(
+        self, container: ops.Container, monkeypatch: pytest.MonkeyPatch
+    ):
+        monkeypatch.setattr(container, '_juju_version', JujuVersion('3.1.5'))
         with pytest.raises(RuntimeError):
             container.exec(['foo'], service_context='srv1')
 
@@ -2658,6 +2662,7 @@ class TestModelBackend:
     def backend(self):
         backend_instance = getattr(self, '_backend', None)
         if backend_instance is None:
+            os.environ['JUJU_VERSION'] = '0.0.0'
             self._backend = _ModelBackend('myapp/0')
         return self._backend
 
@@ -2699,7 +2704,9 @@ class TestModelBackend:
         assert model.unit.is_leader()
 
     def test_relation_tool_errors(self, fake_script: FakeScript, monkeypatch: pytest.MonkeyPatch):
-        monkeypatch.setenv('JUJU_VERSION', '2.8.0')
+        monkeypatch.setattr(
+            self.backend, '_juju_context', _JujuContext.from_dict({'JUJU_VERSION': '2.8.0'})
+        )
         err_msg = 'ERROR invalid value "$2" for option -r: relation not found'
 
         test_cases = [
@@ -2769,14 +2776,18 @@ class TestModelBackend:
         fake_script.write('relation-get', """echo '{"foo": "bar"}' """)
 
         # on 2.7.0+, things proceed as expected
-        monkeypatch.setenv('JUJU_VERSION', version)
+        monkeypatch.setattr(
+            self.backend, '_juju_context', _JujuContext.from_dict({'JUJU_VERSION': version})
+        )
         rel_data = self.backend.relation_get(1, 'foo/0', is_app=True)
         assert rel_data == {'foo': 'bar'}
         calls = [' '.join(i) for i in fake_script.calls(clear=True)]
         assert calls == ['relation-get -r 1 - foo/0 --app --format=json']
 
         # before 2.7.0, it just fails (no --app support)
-        monkeypatch.setenv('JUJU_VERSION', '2.6.9')
+        monkeypatch.setattr(
+            self.backend, '_juju_context', _JujuContext.from_dict({'JUJU_VERSION': '2.6.9'})
+        )
         with pytest.raises(RuntimeError, match='not supported on Juju version 2.6.9'):
             self.backend.relation_get(1, 'foo/0', is_app=True)
         assert fake_script.calls() == []
@@ -2797,7 +2808,9 @@ class TestModelBackend:
                 cat >> {}
                 """).format(pathlib.Path(t.name).as_posix()),
             )
-            monkeypatch.setenv('JUJU_VERSION', version)
+            monkeypatch.setattr(
+                self.backend, '_juju_context', _JujuContext.from_dict({'JUJU_VERSION': version})
+            )
             self.backend.relation_set(1, 'foo', 'bar', is_app=True)
             calls = [' '.join(i) for i in fake_script.calls(clear=True)]
             assert calls == ['relation-set -r 1 --app --file -']
@@ -2809,7 +2822,9 @@ class TestModelBackend:
         assert decoded == 'foo: bar\n'
 
         # before 2.7.0, it just fails always (no --app support)
-        monkeypatch.setenv('JUJU_VERSION', '2.6.9')
+        monkeypatch.setattr(
+            self.backend, '_juju_context', _JujuContext.from_dict({'JUJU_VERSION': '2.6.9'})
+        )
         with pytest.raises(RuntimeError, match='not supported on Juju version 2.6.9'):
             self.backend.relation_set(1, 'foo', 'bar', is_app=True)
         assert fake_script.calls() == []
diff --git a/test/test_storage.py b/test/test_storage.py
index 44091f6..2df43df 100644
--- a/test/test_storage.py
+++ b/test/test_storage.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -49,7 +49,13 @@ class StoragePermutations(abc.ABC):
     ) -> ops.Framework:
         """Create a Framework that we can use to test the backend storage."""
         storage = self.create_storage(request, fake_script)
-        return ops.Framework(storage, None, None, None)  # type: ignore
+        return ops.Framework(
+            storage,
+            None,  # type: ignore
+            None,  # type: ignore
+            None,  # type: ignore
+            juju_debug_at=set(),
+        )
 
     @abc.abstractmethod
     def create_storage(
diff --git a/test/test_testing.py b/test/test_testing.py
index 0ae1b46..8bf8459 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.
+# Copyright 2019 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -39,6 +39,7 @@ import yaml
 import ops
 import ops.testing
 from ops import pebble
+from ops.jujuversion import JujuVersion
 from ops.model import _ModelBackend
 from ops.pebble import FileType
 from ops.testing import ExecResult, _TestingPebbleClient
@@ -6494,7 +6495,7 @@ class TestHandleExec:
         def handler(args: ops.testing.ExecArgs):
             args_history.append(args)
 
-        os.environ['JUJU_VERSION'] = '3.2.1'
+        container._juju_version = JujuVersion('3.2.1')
         harness.handle_exec(container, ['ls'], handler=handler)
 
         container.exec(['ls'], service_context='test').wait()