Skip to content

Commit 050a5d9

Browse files
authored
Merge branch 'main' into grab-workload-version-in-k8s-tutorial
2 parents 80bbc83 + 111e37d commit 050a5d9

16 files changed

Lines changed: 785 additions & 250 deletions

docs/.custom_wordlist.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ balancer's
44
breakpoint
55
callables
66
Canonical's
7+
configurator
78
cosl
89
databag
910
databags
@@ -37,6 +38,7 @@ stdout
3738
storages
3839
systemd
3940
subprocess
41+
teardown
4042
tinyproxy
4143
traceback
4244
tracebacks

docs/howto/initialise-your-project.md

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,41 @@ To check that your preferred name isn't already in use, visit the (future) Charm
2424
```text
2525
https://charmhub.io/mega-calendar-k8s
2626
```
27-
(create-a-repository-and-initialise-it)=
28-
## Create a repository and initialise it
27+
28+
Some charms do not operate a workload, such as integrator and configurator charms. These categories serve different purposes:
29+
30+
* An integrator charm allows integration with a service that is not managed through Juju. This can both apply to server side integrations (such as `s3-integrator`, which integrates an externally managed S3 object storage) or to client side integrations (such as `data-integrator`, representing the integration of external client application that needs a database).
31+
32+
* A configurator charm provides logic to configure a particular charm or relation that is already in Juju. Examples include `cos-configuration` when it applies to a single charm (such as providing more fine-grained configuration of the Prometheus scraping) or for a relation (such as `ingress-configurator` to provide additional configuration of ingress requests).
33+
34+
Since workload-less charms can work both on machines and on Kubernetes, avoid using the `k8s` suffix when naming integrator charms and configurator charms, unless the charm is only relevant for Kubernetes (for example, managing K8s resources within the charm logic using `lightkube`).
35+
36+
When naming the charm, use `-integrator` and `-configurator` to signal the category of the charm. For example, `foo-integrator` or `bar-configurator`.
37+
38+
(create-a-repository)=
39+
## Create a repository
2940

3041
Create a repository with your source control of choice.
3142

3243
```{admonition} Best practice
3344
:class: hint
3445
35-
Name the repository using the pattern ``<charm name>-operator`` for a single
36-
charm, or ``<base charm name>-operators`` when the repository will hold
37-
multiple related charms. For the charm name, see [](#decide-your-charms-name).
46+
If your charm operates a workload, name the repository `<charm name>-operator`. For advice about the charm name, see [](#decide-your-charms-name). If your charm doesn't operate a workload (as in the case of integrator charms and configurator charms), the `-operator` suffix isn't needed. For example, `foo-integrator` and `bar-configurator`.
47+
48+
Repositories that contain multiple charms or one or more charms and other artefacts (like rocks) will need to use other naming patterns.
3849
```
3950

40-
For example, name the repository `mega-calendar-k8s-operator` if your charm will be called `mega-calendar-k8s`.
51+
Examples:
52+
53+
- [kafka-operator](https://github.com/canonical/kafka-operator) - Contains a single charm that operates a machine workload.
54+
- [kafka-k8s-operator](https://github.com/canonical/kafka-k8s-operator) - Contains a single charm that operates a K8s workload.
55+
- [katib-operators](https://github.com/canonical/katib-operators) - Contains multiple charms.
56+
- [data-integrator](https://github.com/canonical/data-integrator) - Contains a charm that integrates an externally managed service (such as a client application).
57+
- [s3-integrator](https://github.com/canonical/object-storage-integrators) - Contains multiple charms that integrate an externally managed service (such as different kinds of object storage backends).
58+
- [request-authentication-configurator](https://github.com/canonical/request-authentication-configurator) - Contains a charm that configures Gateway to perform request authentication.
59+
60+
(initialise-the-repository)=
61+
## Initialise the repository
4162

4263
Next, use {external+charmcraft:doc}`Charmcraft <index>` to generate the recommended project structure in the repository:
4364

docs/howto/manage-storage.md

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,30 @@ self.model.storages.request('cache', 2) # Request two more instances.
8686

8787
The additional instances won't be available immediately after the call. As with `juju add-storage`, your charm will receive a storage-attached event as each additional instance becomes available.
8888

89+
### Clean up storage
90+
91+
Juju emits a [storage-detaching](ops.StorageDetachingEvent) event whenever attached storage is being released: when a user runs `juju detach-storage` (or `juju remove-storage --force`), and during unit teardown. Observe this event to cleanly release resources before the storage goes away.
92+
93+
In the `src/charm.py` file, in the `__init__` function of your charm, set up an observer for the detaching event associated with your storage and pair that with an event handler. For example:
94+
95+
```python
96+
framework.observe(
97+
self.on['cache'].storage_detaching, self._on_storage_detaching
98+
)
99+
```
100+
101+
Now, in the body of the charm class, define the event handler, or adjust an existing holistic one. For example, to warn users that data won't be cached:
102+
103+
```python
104+
def _on_storage_detaching(self, event: ops.StorageDetachingEvent):
105+
"""Handle the storage being detached."""
106+
self.unit.status = ops.ActiveStatus(
107+
'Caching disabled; provide storage to boost performance'
108+
)
109+
```
110+
111+
> Examples: [MongoDB updating the set before storage is removed](https://github.com/canonical/mongodb-operator/blob/b33d036173f47c68823e08a9f03189dc534d38dc/src/charm.py#L596)
112+
89113
## Manage storage for a Kubernetes charm
90114

91115
### Define the storage
@@ -164,29 +188,11 @@ charm_cache_root = pathops.LocalPath(charm_cache_path)
164188

165189
Alternatively, use {external+charmlibs:class}`pathops.ContainerPath` to access `web_cache_path` in the workload container. This approach is more appropriate if you need to reference additional data in the workload container.
166190

167-
## Handle storage detaching
168-
169-
In the `src/charm.py` file, in the `__init__` function of your charm, set up an observer for the detaching event associated with your storage and pair that with an event handler. For example:
170-
171-
```python
172-
framework.observe(
173-
self.on['cache'].storage_detaching, self._on_storage_detaching
174-
)
175-
```
176-
177-
> See more: [](ops.StorageDetachingEvent)
178-
179-
Now, in the body of the charm definition, define the event handler, or adjust an existing holistic one. For example, to warn users that data won't be cached:
191+
### Clean up storage
180192

181-
```python
182-
def _on_storage_detaching(self, event: ops.StorageDetachingEvent):
183-
"""Handle the storage being detached."""
184-
self.unit.status = ops.ActiveStatus(
185-
'Caching disabled; provide storage to boost performance'
186-
)
187-
```
193+
On Kubernetes, `juju detach-storage` isn't supported, so storage is only detached during unit teardown. Juju emits a [storage-detaching](ops.StorageDetachingEvent) event during teardown, along with `stop`, `remove`, and other teardown events.
188194

189-
> Examples: [MySQL handling cluster management](https://github.com/canonical/mysql-k8s-operator/blob/4c575b478b7ae2a28b09dde9cade2d3370dd4db6/src/charm.py#L823), [MongoDB updating the set before storage is removed](https://github.com/canonical/mongodb-operator/blob/b33d036173f47c68823e08a9f03189dc534d38dc/src/charm.py#L596)
195+
> Examples: [MySQL handling cluster management](https://github.com/canonical/mysql-k8s-operator/blob/4c575b478b7ae2a28b09dde9cade2d3370dd4db6/src/charm.py#L823)
190196

191197
## Write unit tests
192198

docs/reuse/best-practices.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
**[How to initialise your project](https://documentation.ubuntu.com/ops/latest/howto/initialise-your-project/)**
2-
- Name the repository using the pattern ``<charm name>-operator`` for a single charm, or ``<base charm name>-operators`` when the repository will hold multiple related charms. For the charm name, see [](#decide-your-charms-name). See [Create a repository and initialise it](#create-a-repository-and-initialise-it).
2+
- If your charm operates a workload, name the repository `<charm name>-operator`. For advice about the charm name, see [](#decide-your-charms-name). If your charm doesn't operate a workload (as in the case of integrator charms and configurator charms), the `-operator` suffix isn't needed. For example, `foo-integrator` and `bar-configurator`. Repositories that contain multiple charms or one or more charms and other artefacts (like Rocks) will need to use other naming patterns. See [Create a repository](#create-a-repository).
33

44
**[How to log from your charm](https://documentation.ubuntu.com/ops/latest/howto/log-from-your-charm/)**
55
- Capture output to `stdout` and `stderr` in your charm and use the logging and warning functionality to send messages to the charm user, rather than rely on Juju capturing output. In particular, you should avoid `print()` calls, and ensure that any subprocess calls capture output.

docs/tutorial/write-your-first-machine-charm.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,7 @@ def test_version(monkeypatch: pytest.MonkeyPatch):
707707
We'll run all the tests later in the tutorial. But if you'd like to see whether this test passes, run:
708708

709709
```text
710-
tox -e unit -- tests/unit/test_tinyproxy.py
710+
tox -e unit -- -k test_version
711711
```
712712

713713
### Write state-transition tests

ops/framework.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,12 @@ def __init__(
647647
self._breakpoint_welcomed: bool = False
648648
self._juju_debug_at = juju_debug_at or set()
649649

650+
# Set to True once close() has run. Callers that close defensively
651+
# check this rather than calling close() again: charm libraries (for
652+
# example tempo's charm_tracing) replace the instance's close with a
653+
# wrapper that is not safe to invoke twice.
654+
self._closed: bool = False
655+
650656
def set_breakpointhook(self) -> Any | None:
651657
"""Hook into ``sys.breakpointhook`` so the builtin ``breakpoint()`` works as expected.
652658
@@ -669,6 +675,7 @@ def set_breakpointhook(self) -> Any | None:
669675
def close(self) -> None:
670676
"""Close the underlying backends."""
671677
self._storage.close()
678+
self._closed = True
672679

673680
def _track(self, obj: Serializable):
674681
"""Track object and ensure it is the only object created using its handle path."""

ops/pebble.py

Lines changed: 125 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ def __enter__(self) -> typing.IO[typing.AnyStr]: ...
340340

341341

342342
class _WebSocket(Protocol):
343+
sock: socket.socket | None
344+
343345
def connect(self, url: str, socket: socket.socket): ...
344346

345347
def shutdown(self): ...
@@ -410,12 +412,39 @@ def _format_timeout(timeout: float) -> str:
410412

411413

412414
def _start_thread(target: Callable[..., Any], *args: Any, **kwargs: Any) -> threading.Thread:
413-
"""Helper to simplify starting a thread."""
414-
thread = threading.Thread(target=target, args=args, kwargs=kwargs)
415+
"""Helper to simplify starting a thread.
416+
417+
The thread is marked as daemon so that exec I/O threads can never keep
418+
the interpreter alive at exit, even when teardown is missed (for
419+
example, if wait() is never called and the server holds the websockets
420+
open).
421+
"""
422+
thread = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True)
415423
thread.start()
416424
return thread
417425

418426

427+
def _force_close_websocket(ws: _WebSocket):
428+
"""Close a websocket so that reads and writes blocked on it unblock.
429+
430+
``WebSocket.shutdown()`` only closes the socket's file descriptor, and
431+
closing a socket doesn't wake up other threads that are blocked in
432+
``recv()`` on the same socket; an OS-level shutdown of the connection
433+
does.
434+
435+
This reaches into websocket-client's ``WebSocket.sock`` (the underlying
436+
``socket.socket``) because the library exposes no public way to shut the
437+
connection down without first closing the fd.
438+
"""
439+
sock = ws.sock
440+
if sock is not None:
441+
try:
442+
sock.shutdown(socket.SHUT_RDWR)
443+
except OSError:
444+
pass # Already disconnected or closed.
445+
ws.shutdown()
446+
447+
419448
class Error(Exception):
420449
"""Base class of most errors raised by the Pebble client."""
421450

@@ -1787,20 +1816,35 @@ def _wait(self) -> int:
17871816
if timeout is not None:
17881817
# A bit more than the command timeout to ensure that happens first
17891818
timeout += 1
1790-
change = self._client.wait_change(self._change_id, timeout=timeout)
1819+
try:
1820+
change = self._client.wait_change(self._change_id, timeout=timeout)
1821+
except BaseException:
1822+
# The wait failed or was interrupted, for example because the
1823+
# change didn't finish before the client-side timeout above. The
1824+
# I/O threads may still be blocked on their websockets, so tear
1825+
# the connections down to unblock them, and reap the threads
1826+
# before propagating the error, otherwise they're left running
1827+
# and at interpreter shutdown block the join of non-daemon
1828+
# threads (#2556).
1829+
self._teardown_after_error()
1830+
raise
17911831

17921832
# If stdin reader thread is running, stop it
17931833
if self._cancel_stdin is not None:
17941834
self._cancel_stdin()
1835+
self._cancel_stdin = None
17951836

17961837
# Wait for all threads to finish (e.g., message barrier sent)
17971838
for thread in self._threads:
17981839
thread.join()
17991840

18001841
# If we opened a cancel_reader pipe, close the read side now (write
1801-
# side was already closed by _cancel_stdin().
1842+
# side was already closed by _cancel_stdin()). Clear it afterwards so
1843+
# that a second call to _wait() (e.g. wait() then wait_output()) can't
1844+
# close the same fd again, matching _teardown_after_error().
18021845
if self._cancel_reader is not None:
18031846
os.close(self._cancel_reader)
1847+
self._cancel_reader = None
18041848

18051849
# Close websockets (shutdown doesn't send CLOSE message or wait for response).
18061850
self._control_ws.shutdown()
@@ -1816,6 +1860,35 @@ def _wait(self) -> int:
18161860
exit_code = change.tasks[0].data.get('exit-code', -1)
18171861
return exit_code
18181862

1863+
def _teardown_after_error(self):
1864+
# If stdin reader thread is running, stop it.
1865+
if self._cancel_stdin is not None:
1866+
self._cancel_stdin()
1867+
self._cancel_stdin = None
1868+
1869+
# Unlike the success path, close the websockets before joining the
1870+
# I/O threads: the server hasn't finished the exec, so the threads
1871+
# are still blocked reading from the websockets and would never
1872+
# finish on their own.
1873+
_force_close_websocket(self._control_ws)
1874+
_force_close_websocket(self._stdio_ws)
1875+
if self._stderr_ws is not None:
1876+
_force_close_websocket(self._stderr_ws)
1877+
1878+
for thread in self._threads:
1879+
# With the websockets closed the threads exit almost immediately;
1880+
# the timeout is belt-and-braces (they're daemon threads, so even
1881+
# a leak here can't block interpreter shutdown).
1882+
thread.join(timeout=1.0)
1883+
if thread.is_alive():
1884+
logger.warning('exec I/O thread did not finish after error in wait')
1885+
1886+
# If we opened a cancel_reader pipe, close the read side now (write
1887+
# side was already closed by _cancel_stdin()).
1888+
if self._cancel_reader is not None:
1889+
os.close(self._cancel_reader)
1890+
self._cancel_reader = None
1891+
18191892
def wait_output(self) -> tuple[AnyStr, AnyStr | None]:
18201893
"""Wait for the process to finish and return tuple of (stdout, stderr).
18211894
@@ -1896,29 +1969,43 @@ def _reader_to_websocket(
18961969
bufsize: int = 16 * 1024,
18971970
):
18981971
"""Read reader through to EOF and send each chunk read to the websocket."""
1899-
while True:
1900-
if cancel_reader is not None:
1901-
# Wait for either a read to be ready or the caller to cancel stdin.
1902-
# If the reader is also ready, drain it first so a cancel that
1903-
# arrives before this thread runs doesn't drop pending input.
1904-
result = select.select([cancel_reader, reader], [], [])
1905-
if reader not in result[0]:
1906-
break
1972+
try:
1973+
while True:
1974+
if cancel_reader is not None:
1975+
# Wait for either a read to be ready or the caller to cancel stdin.
1976+
# If the reader is also ready, drain it first so a cancel that
1977+
# arrives before this thread runs doesn't drop pending input.
1978+
result = select.select([cancel_reader, reader], [], [])
1979+
if reader not in result[0]:
1980+
break
19071981

1908-
chunk = reader.read(bufsize)
1909-
if not chunk:
1910-
break
1911-
if isinstance(chunk, str):
1912-
chunk = chunk.encode(encoding)
1913-
ws.send_binary(chunk)
1982+
chunk = reader.read(bufsize)
1983+
if not chunk:
1984+
break
1985+
if isinstance(chunk, str):
1986+
chunk = chunk.encode(encoding)
1987+
ws.send_binary(chunk)
19141988

1915-
ws.send('{"command":"end"}') # Send "end" command as TEXT frame to signal EOF
1989+
ws.send('{"command":"end"}') # Send "end" command as TEXT frame to signal EOF
1990+
except (websocket.WebSocketException, OSError) as e:
1991+
# The websocket or cancel pipe was closed underneath us (for example,
1992+
# because the exec failed and ExecProcess._wait tore the connection
1993+
# down), so there's nowhere to send the rest of the input to.
1994+
logger.debug('exec stdin forwarder stopped: %s', e)
19161995

19171996

19181997
def _websocket_to_writer(ws: _WebSocket, writer: _WebsocketWriter, encoding: str | None):
19191998
"""Receive messages from websocket (until end signal) and write to writer."""
19201999
while True:
1921-
chunk = ws.recv()
2000+
try:
2001+
chunk = ws.recv()
2002+
except (websocket.WebSocketException, OSError) as e:
2003+
# The websocket was closed underneath us (for example, because
2004+
# the exec failed and ExecProcess._wait tore the connection
2005+
# down): treat it as end-of-stream rather than crashing the
2006+
# thread.
2007+
logger.debug('exec output forwarder stopped: %s', e)
2008+
break
19222009

19232010
if isinstance(chunk, str):
19242011
try:
@@ -1996,7 +2083,16 @@ def read(self, n: int = -1) -> str | bytes:
19962083
return b''
19972084

19982085
while not self.remaining:
1999-
chunk = self.ws.recv()
2086+
try:
2087+
chunk = self.ws.recv()
2088+
except (websocket.WebSocketException, OSError) as e:
2089+
# The websocket was closed underneath us (for example,
2090+
# because the exec failed and ExecProcess._wait tore the
2091+
# connection down): treat it as end-of-file. The error that
2092+
# caused the teardown is reported by wait()/wait_output().
2093+
logger.debug('exec output stream closed: %s', e)
2094+
self.eof = True
2095+
return b''
20002096

20012097
if isinstance(chunk, str):
20022098
try:
@@ -3155,14 +3251,21 @@ def exec(
31553251
task_id = resp['result']['task-id']
31563252

31573253
stderr_ws: _WebSocket | None = None
3254+
connected: list[_WebSocket] = []
31583255
try:
31593256
control_ws = self._connect_websocket(task_id, 'control')
3257+
connected.append(control_ws)
31603258
stdio_ws = self._connect_websocket(task_id, 'stdio')
3259+
connected.append(stdio_ws)
31613260
if not combine_stderr:
31623261
stderr_ws = self._connect_websocket(task_id, 'stderr')
3262+
connected.append(stderr_ws)
31633263
except websocket.WebSocketException as e:
31643264
# Error connecting to websockets, probably due to the exec/change
3165-
# finishing early with an error. Call wait_change to pick that up.
3265+
# finishing early with an error. Close any websockets that did
3266+
# connect, then call wait_change to pick up the change error.
3267+
for ws in connected:
3268+
ws.shutdown()
31663269
change = self.wait_change(ChangeID(change_id))
31673270
if change.err:
31683271
raise ChangeError(change.err, change) from e
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
uv.lock
2+
*.tar.gz
3+
*.charm

0 commit comments

Comments
 (0)