Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9037c7e
feat: replace ops.model.Port with hookcmds.Port
james-garner-canonical Mar 5, 2026
e5c6f72
feat: update ops port methods to support ranges and endpoints
james-garner-canonical Mar 5, 2026
b589903
chore: drop comment and add init=False
james-garner-canonical Mar 5, 2026
dabc9b8
chore: comment on outstanding problem with endpoints normalisation
james-garner-canonical Mar 5, 2026
6a5c1ad
docs: exclude Port from hookcmds docs and redirect to ops.Port
james-garner-canonical Mar 5, 2026
c3a23a6
feat: support port ranges in testing
james-garner-canonical Mar 8, 2026
d1db204
feat: bring back separate ops.model.Port class
james-garner-canonical Mar 8, 2026
8e19250
docs: drop spurious diff
james-garner-canonical Mar 8, 2026
8eff659
chore: revert endpoints parsing change and add comment
james-garner-canonical Mar 8, 2026
6890fdc
style: use match case to switch on ['*'] more explicitly
james-garner-canonical Mar 8, 2026
9adbc6a
feat: add port range support to Unit.open/close_port
james-garner-canonical Mar 8, 2026
736281c
test: thanks for the open/close port range tests, Claude
james-garner-canonical Mar 8, 2026
0a1c7d5
feat: hookcmds treat port=None, to_port=ABCD as an error
james-garner-canonical Mar 8, 2026
ea70537
test: add a basic test for our harness port range support
james-garner-canonical Mar 8, 2026
5740058
feat: handle stdout-only errors from open/close-port
james-garner-canonical Mar 9, 2026
eca96c6
feat: support port ranges in scenario
james-garner-canonical Mar 9, 2026
a4f7864
fix: errors in testing
james-garner-canonical Mar 9, 2026
33e29e7
style: error message
james-garner-canonical Mar 9, 2026
f0615c2
style: keep diff clean
james-garner-canonical Mar 9, 2026
db71dbb
style: drop now-unnecessary comments
james-garner-canonical Mar 9, 2026
39ea347
feat: add endpoint support to open/close_port
james-garner-canonical Mar 9, 2026
de8ced4
chore: make Port.endpoints keyword only
james-garner-canonical Mar 9, 2026
e2c36cf
docs: document the `endpoints` arguments of open/close_ports
james-garner-canonical Mar 9, 2026
e4a8ab1
feat: support endpoints in harness backend open/close_ports
james-garner-canonical Mar 9, 2026
2440682
docs: add comment to note limited scope of harness support
james-garner-canonical Mar 9, 2026
e167b25
test: add a test for the harness happy path with endpoints
james-garner-canonical Mar 9, 2026
d211adf
test: update ops tests to expect --endpoints in subprocess calls
james-garner-canonical Mar 9, 2026
054b2d3
feat: model Juju's port range and endpoint handling in scenario
james-garner-canonical Mar 9, 2026
f8f9319
style: drop redundant and long line
james-garner-canonical Mar 9, 2026
bbaa6a5
style: naming
james-garner-canonical Mar 9, 2026
14943cc
refactor: reify port mapping type and tidy up
james-garner-canonical Mar 9, 2026
13d1c19
feat: support tuples representation of ports in set_ports
james-garner-canonical Mar 9, 2026
3627956
test: tests of endpoints and ranges (thanks Claude)
james-garner-canonical Mar 9, 2026
271657c
fix: return overlapping port rather than query port
james-garner-canonical Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions ops/_private/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -3000,15 +3000,39 @@ def secret_remove(self, id: str, *, revision: int | None = None) -> None:
else:
self._secrets = [s for s in self._secrets if not self._secret_ids_are_equal(s.id, id)]

def open_port(self, protocol: str, port: int | None = None):
def open_port(
self,
protocol: str,
port: int | None = None,
*,
to_port: int | None = None,
endpoints: Sequence[str] = '*',
):
self._check_protocol_and_port(protocol, port)
protocol_lit = cast('Literal["tcp", "udp", "icmp"]', protocol)
self._opened_ports.add(model.Port(protocol_lit, port))

def close_port(self, protocol: str, port: int | None = None):
endpoints = tuple(endpoints) if endpoints != '*' else '*'
# This only really works for the happy path.
# We should really be checking for overlapping port ranges (and erroring),
# and merging with existing entries for endpoints, but since harness is deprecated,
# and this is only needed for charms adopting this new feature, we can get away with it.
self._opened_ports.add(model.Port(protocol_lit, port, to_port, endpoints=endpoints))

def close_port(
self,
protocol: str,
port: int | None = None,
*,
to_port: int | None = None,
endpoints: Sequence[str] = '*',
):
self._check_protocol_and_port(protocol, port)
protocol_lit = cast('Literal["tcp", "udp", "icmp"]', protocol)
self._opened_ports.discard(model.Port(protocol_lit, port))
endpoints = tuple(endpoints) if endpoints != '*' else '*'
# This only really works for the happy path.
# We should really be checking for overlapping port ranges (and erroring),
# and merging with existing entries for endpoints, but since harness is deprecated,
# and this is only needed for charms adopting this new feature, we can get away with it.
self._opened_ports.discard(model.Port(protocol_lit, port, to_port, endpoints=endpoints))

def opened_ports(self) -> set[model.Port]:
return set(self._opened_ports)
Expand Down
30 changes: 21 additions & 9 deletions ops/hookcmds/_port.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,22 @@ def close_port(
*,
to_port: int | None = None,
endpoints: str | Iterable[str] | None = None,
) -> None: ...
) -> str | None: ...
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a bug in Juju, or at least a wart. I don't think we should return stdout from hookcmds, but detect the error (from stdout) and raise an exception here. We should also open a bug on Juju to return a non-zero exit code for this case.

@overload
def close_port(
protocol: str | None,
port: int,
*,
to_port: int | None = None,
endpoints: str | Iterable[str] | None = None,
) -> None: ...
) -> str | None: ...
def close_port(
protocol: str | None = None,
port: int | None = None,
*,
to_port: int | None = None,
endpoints: str | Iterable[str] | None = None,
):
) -> str | None:
"""Register a request to close a port or port range.

For more details, see:
Expand All @@ -58,13 +58,16 @@ def close_port(
if port is None:
if protocol is None:
raise TypeError('You must provide a port or protocol.')
if to_port is not None:
raise TypeError('to_port cannot be specified if port is not specified')
args.append(protocol)
else:
port_arg = f'{port}-{to_port}' if to_port is not None else str(port)
if protocol is not None:
port_arg = f'{port_arg}/{protocol}'
args.append(port_arg)
run('close-port', *args)
result = run('close-port', *args).strip()
return result or None


@overload
Expand All @@ -74,22 +77,22 @@ def open_port(
*,
to_port: int | None = None,
endpoints: str | Iterable[str] | None = None,
) -> None: ...
) -> str | None: ...
@overload
def open_port(
protocol: str | None,
port: int,
*,
to_port: int | None = None,
endpoints: str | Iterable[str] | None = None,
) -> None: ...
) -> str | None: ...
def open_port(
protocol: str | None = None,
port: int | None = None,
*,
to_port: int | None = None,
endpoints: str | Iterable[str] | None = None,
):
) -> str | None:
"""Register a request to open a port or port range.

For more details, see:
Expand All @@ -113,13 +116,21 @@ def open_port(
if port is None:
if protocol is None:
raise TypeError('You must provide a port or protocol.')
if to_port is not None:
raise TypeError('to_port can only be specified if port is also specified')
args.append(protocol)
else:
port_arg = f'{port}-{to_port}' if to_port is not None else str(port)
if protocol is not None:
port_arg = f'{port_arg}/{protocol}'
args.append(port_arg)
run('open-port', *args)
# In the happy case (already open or opened successfully) open-ports exits silently with 0.
# If open-port exits with an error code, then run will raise an Error.
# Specifying a non-existent endpoint exits with 0, but prints an error message,
# **and does not open the port**. In this case, we return the error message.
# Ops will use this to raise an error.
result = run('open-port', *args).strip()
return result or None


def opened_ports(*, endpoints: bool = False) -> list[Port]:
Expand All @@ -146,7 +157,8 @@ def opened_ports(*, endpoints: bool = False) -> list[Port]:
# '8000-8999/tcp' or '8000-8999/udp' (where the two numbers can be any ports)
# '8000-8999' (where these could be any port number)
# If ``--endpoints`` is used, then each port will be followed by a
# (possibly empty) tuple of endpoints.
# (non-empty) tuple of endpoints, e.g. '8000-8999/tcp (ep1,ep2)' or '80/tcp (*)'.
# (*) indicates that the port applies to all endpoints.
for port in result:
if endpoints:
port, port_endpoints = port.rsplit(' ', 1)
Expand Down
3 changes: 0 additions & 3 deletions ops/hookcmds/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,6 @@ def _from_dict(cls, d: dict[str, Any]) -> Network:
return cls(bind_addresses=bind, egress_subnets=egress, ingress_addresses=ingress)


# Note that we intend to merge this with model.py's `Port` in the future, and
# that does not have `kw_only=True`. That means that we should not use it here,
# either, so that merging can be backwards compatible.
@dataclasses.dataclass(frozen=True)
class Port:
"""A port that Juju has opened for the charm."""
Expand Down
139 changes: 107 additions & 32 deletions ops/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
import warnings
import weakref
from abc import ABC, abstractmethod
from collections.abc import Callable, Generator, Iterable, Mapping, MutableMapping
from collections.abc import Callable, Generator, Iterable, Mapping, MutableMapping, Sequence
from pathlib import Path, PurePath
from typing import (
Any,
Expand Down Expand Up @@ -719,7 +719,11 @@ def add_secret(
)

def open_port(
self, protocol: typing.Literal['tcp', 'udp', 'icmp'], port: int | None = None
self,
protocol: typing.Literal['tcp', 'udp', 'icmp'],
port: int | tuple[int, int | None] | None = None,
*,
endpoints: Sequence[str] = '*',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be better / more Pythonic for this input parameter, so endpoints: Sequence[str] | None = None.

) -> None:
"""Open a port with the given protocol for this unit.

Expand All @@ -736,18 +740,30 @@ def open_port(
protocol: String representing the protocol; must be one of
'tcp', 'udp', or 'icmp' (lowercase is recommended, but
uppercase is also supported).
port: The port to open. Required for TCP and UDP; not allowed
for ICMP.
port: The port to open. Required for TCP and UDP; not allowed for ICMP.
May be a tuple of two integers to specify a port range.
endpoints: The endpoints for which to open the port.
'*' means to open the port for all endpoints.

Raises:
ModelError: If ``port`` is provided when ``protocol`` is 'icmp'
or ``port`` is not provided when ``protocol`` is 'tcp' or
'udp'.
"""
self._backend.open_port(protocol.lower(), port)
if isinstance(port, tuple):
port, to_port = port
else:
port, to_port = port, None
if not endpoints:
raise TypeError('endpoints must be a non-empty string or sequence of strings')
self._backend.open_port(protocol.lower(), port, to_port=to_port, endpoints=endpoints)

def close_port(
self, protocol: typing.Literal['tcp', 'udp', 'icmp'], port: int | None = None
self,
protocol: typing.Literal['tcp', 'udp', 'icmp'],
port: int | tuple[int, int | None] | None = None,
*,
endpoints: Sequence[str] = '*',
) -> None:
"""Close a port with the given protocol for this unit.

Expand All @@ -765,21 +781,29 @@ def close_port(
protocol: String representing the protocol; must be one of
'tcp', 'udp', or 'icmp' (lowercase is recommended, but
uppercase is also supported).
port: The port to open. Required for TCP and UDP; not allowed
for ICMP.
port: The port to open. Required for TCP and UDP; not allowed for ICMP.
May be a tuple of two integers to specify a port range.
endpoints: The endpoints for which to close the port.
'*' means to close the port for all endpoints.

Raises:
ModelError: If ``port`` is provided when ``protocol`` is 'icmp'
or ``port`` is not provided when ``protocol`` is 'tcp' or
'udp'.
"""
self._backend.close_port(protocol.lower(), port)
if isinstance(port, tuple):
port, to_port = port
else:
port, to_port = port, None
if not endpoints:
raise TypeError('endpoints must be a non-empty string or sequence of strings')
self._backend.close_port(protocol.lower(), port, to_port=to_port, endpoints=endpoints)

def opened_ports(self) -> set[Port]:
"""Return a list of opened ports for this unit."""
return self._backend.opened_ports()

def set_ports(self, *ports: int | Port) -> None:
def set_ports(self, *ports: int | tuple[int, int | None] | Port) -> None:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per discussion, I'd be inclined to drop this change, and they can pass in Port with to_port if they need it.

"""Set the open ports for this unit, closing any others that are open.

Some behaviour, such as whether the port is opened or closed externally without
Expand All @@ -800,16 +824,19 @@ def set_ports(self, *ports: int | Port) -> None:
``port`` is not ``None``, or where ``protocol`` is 'tcp' or 'udp' and ``port``
is ``None``.
"""
# Normalise to get easier comparisons.
existing = {(port.protocol, port.port) for port in self._backend.opened_ports()}
existing = self._backend.opened_ports()
desired = {
('tcp', port) if isinstance(port, int) else (port.protocol, port.port)
Port('tcp', port)
if isinstance(port, int)
else Port('tcp', port[0], to_port=port[1])
if isinstance(port, tuple)
else port
for port in ports
}
for protocol, port in existing - desired:
self._backend.close_port(protocol, port)
for protocol, port in desired - existing:
self._backend.open_port(protocol, port)
for p in existing - desired:
self._backend.close_port(p.protocol, p.port, to_port=p.to_port, endpoints=p.endpoints)
for p in desired - existing:
self._backend.open_port(p.protocol, p.port, to_port=p.to_port, endpoints=p.endpoints)

def reboot(self, now: bool = False) -> None:
"""Reboot the host machine.
Expand Down Expand Up @@ -846,6 +873,21 @@ class Port:
port: int | None
"""The port number. Will be ``None`` if protocol is ``'icmp'``."""

to_port: int | None = None
"""The end of the port range, if a range was specified.

Will be ``None`` if a single port was specified (or if protocol is ``'icmp'``).
"""

_: dataclasses.KW_ONLY

endpoints: tuple[str, ...] | Literal['*'] = '*'
"""The endpoints for which the port is open.

Will be ``"*"`` if open for all endpoints,
or a tuple of endpoint names if specified for particular endpoints.
"""


OpenedPort = Port
"""Alias to Port for backwards compatibility.
Expand Down Expand Up @@ -4037,26 +4079,59 @@ def secret_remove(self, id: str, *, revision: int | None = None):
with self._wrap_hookcmd('secret-remove', id=id, revision=revision):
hookcmds.secret_remove(id, revision=revision)

def open_port(self, protocol: str, port: int | None = None):
with self._wrap_hookcmd('open-port', protocol=protocol, port=port):
hookcmds.open_port(protocol, port)
def open_port(
self,
protocol: str,
port: int | None = None,
*,
to_port: int | None = None,
endpoints: Sequence[str] = '*',
):
with self._wrap_hookcmd(
'open-port', protocol=protocol, port=port, to_port=to_port, endpoints=endpoints
):
result = hookcmds.open_port(protocol, port, to_port=to_port, endpoints=endpoints)
if result is not None:
raise ModelError(result)

def close_port(self, protocol: str, port: int | None = None):
with self._wrap_hookcmd('close-port', protocol=protocol, port=port):
hookcmds.close_port(protocol, port)
def close_port(
self,
protocol: str,
port: int | None = None,
*,
to_port: int | None = None,
endpoints: Sequence[str] = '*',
):
with self._wrap_hookcmd(
'close-port', protocol=protocol, port=port, to_port=to_port, endpoints=endpoints
):
result = hookcmds.close_port(protocol, port, to_port=to_port, endpoints=endpoints)
if result is not None:
raise ModelError(result)

def opened_ports(self) -> set[Port]:
with self._wrap_hookcmd('opened-ports'):
results = hookcmds.opened_ports()
with self._wrap_hookcmd('opened-ports', endpoints=True):
result = hookcmds.opened_ports(endpoints=True)
ports: set[Port] = set()
for raw_port in results:
if raw_port.protocol not in ('tcp', 'udp', 'icmp'):
logger.warning('Unexpected opened-ports protocol: %s', raw_port.protocol)
for port in result:
if port.protocol not in ('tcp', 'udp', 'icmp'):
logger.warning('Unexpected opened-ports protocol: %s', port.protocol)
continue
if not port.endpoints:
logger.warning('opened-ports result with no endpoints: %s', port)
continue
if raw_port.to_port is not None:
logger.warning('Ignoring opened-ports port range: %s', raw_port)
port = Port(raw_port.protocol or 'tcp', raw_port.port)
ports.add(port)
match port.endpoints:
case ['*']:
model_endpoints = '*'
case _:
model_endpoints = tuple(port.endpoints)
model_port = Port(
port.protocol,
port.port,
to_port=port.to_port,
endpoints=model_endpoints,
)
ports.add(model_port)
return ports

def reboot(self, now: bool = False):
Expand Down
Loading