Repository: canonical/operator

Issue:
Juju secret expiry is null
## Description

We have a charm that creates a secret with an expiry date on a given event and reads that secret on another event. During that second event, the expiry shows up as None when we would have expected it to be the value we initially set it to. 

### Example charm

In this example charm we would expect the charm status to be `Secret expires on: ...`. Instead we see that the expiry date is not set. The content is set correctly however.

```python
#!/usr/bin/env python3
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

"""My charm."""

import datetime
import logging
from typing import Optional

from ops.charm import CharmBase, CollectStatusEvent
from ops.framework import EventBase
from ops.main import main
from ops.model import ActiveStatus, BlockedStatus, SecretNotFoundError, WaitingStatus

logger = logging.getLogger(__name__)

SECRET_LABEL = "food"


class MyCharm(CharmBase):
    """Main class to handle Juju events."""

    def __init__(self, *args):
        super().__init__(*args)
        self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status)
        self.framework.observe(self.on.update_status, self._configure)
        self.framework.observe(self.on.config_changed, self._configure)

    def _on_collect_unit_status(self, event: CollectStatusEvent):
        """Centralized status management for the charm."""
        if not self.unit.is_leader():
            event.add_status(BlockedStatus("Scaling is not implemented for this charm"))
            return
        if not self._secret_is_created():
            event.add_status(WaitingStatus("Waiting for the secret to be created"))
            return
        secret_expiry = self._get_secret_expiry()
        if not secret_expiry:
            event.add_status(ActiveStatus("Error: Secret expiry not found"))
            return
        event.add_status(ActiveStatus("Secret expires on: {}".format(secret_expiry)))

    def _configure(self, event: EventBase) -> None:
        if not self._secret_is_created():
            self._create_secret()

    def _create_secret(self):
        """Create a secret."""
        secret_content = {
            "food": "apple",
        }
        self.app.add_secret(
            content=secret_content,
            label=SECRET_LABEL,
            expire=datetime.timedelta(days=20),
        )

    def _secret_is_created(self) -> bool:
        """Check if the secret is created."""
        try:
            self.model.get_secret(label=SECRET_LABEL)
            return True
        except SecretNotFoundError:
            return False

    def _get_secret_expiry(self) -> Optional[datetime.datetime]:
        """Get the expiry date of the secret."""
        secret = self.model.get_secret(label=SECRET_LABEL)
        secret_info = secret.get_info()
        secret_content = secret.get_content()
        logger.info("Secret info: %s", secret_info)
        logger.info("Secret content: %s", secret_content)
        return secret_info.expires


if __name__ == "__main__":
    main(MyCharm)
```

### Logs

```
unit-self-signed-certificates-0: 16:42:26 INFO unit.self-signed-certificates/0.juju-log Secret info: SecretInfo(id='secret:cqv6gcfmp25c77ug4n5g', label='food', revision=1, expires=None, rotation=SecretRotate.NEVER, rotates=None)
unit-self-signed-certificates-0: 16:42:26 INFO unit.self-signed-certificates/0.juju-log Secret content: {'food': 'apple'}
```

```logs
guillaume@thinkpad:~/code/self-signed-certificates-operator$ juju status
Model  Controller          Cloud/Region        Version  SLA          Timestamp
ddd    microk8s-localhost  microk8s/localhost  3.5.1    unsupported  16:36:48-04:00

App                       Version  Status  Scale  Charm                     Channel  Rev  Address         Exposed  Message
self-signed-certificates           active      1  self-signed-certificates             0  10.152.183.137  no       Error: Secret expiry not found

Unit                         Workload  Agent  Address      Ports  Message
self-signed-certificates/0*  active    idle   10.1.19.182         Error: Secret expiry not found
```

## Is the issue in Juju?

At this point I don't know whether the issue is with `ops`, with Juju, or with me.

## Environment

- Juju: `3.5.1`
- Ops: `2.15.0`
- MicroK8s: `1.29-strict/stable`

Discussion:

Patch:
diff --git a/ops/model.py b/ops/model.py
index 3a9a1cf..f801a5b 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -1182,7 +1182,7 @@ class SecretInfo:
     @classmethod
     def from_dict(cls, id: str, d: Dict[str, Any]) -> 'SecretInfo':
         """Create new SecretInfo object from ID and dict parsed from JSON."""
-        expires = typing.cast(Optional[str], d.get('expires'))
+        expires = typing.cast(Optional[str], d.get('expiry'))
         try:
             rotation = SecretRotate(typing.cast(Optional[str], d.get('rotation')))
         except ValueError:


Test patch:
diff --git a/test/test_model.py b/test/test_model.py
index 0100570..e41ff03 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -3568,7 +3568,7 @@ class TestSecretInfo:
             {
                 'label': 'fromdict',
                 'revision': 8,
-                'expires': '2022-12-09T14:10:00Z',
+                'expiry': '2022-12-09T14:10:00Z',
                 'rotation': 'yearly',
                 'rotates': '2023-01-09T14:10:00Z',
             },