IP Router Interface
- Canonical Telco
Channel | Revision | Published | Runs on |
---|---|---|---|
latest/edge | 2 | 20 Oct 2023 |
juju deploy ip-router-interface --channel edge
Deploy universal operators easily with Juju, the Universal Operator Lifecycle Manager.
Platform:
22.04
charms.ip_router_interface.v0.ip_router_interface
-
- Last updated 19 Oct 2023
- Revision Library version 0.2
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.
"""Library for the ip-router integration
This library contains the Requires and Provides classes for interactions through the
ip-router interface
## Getting Started
From a charm directory, fetch the library using `charmcraft`:
```shell
charmcraft fetch-lib charms.ip_router_interface.v0.ip_router_interface
```
### Provider charm
This example provider charm is all we need to listen to ip-router requirer requests.
The ip-router provider fulfills the routing function for multiple charms that are
requirers of the ip-router interface. For that reason, this charm will continuously
track and update all of the connected requirers and the networks and routes they've
requested, which it does in a routing table. As new charms are connected and disconnected
to the relation, this routing table is automatically adds and removes the dependent
networks.
The library handles the listening and synchronization for all of the ip-router network
requests internally, which means as the charm author you don't need to worry about any
of the business logic of validating or orchestrating the relation network.
You can also listen to the `routing_table_updated` event that is emitted after the
tables are synced.
```python
import logging, json
import ops
from charms.ip_router_interface.v0.ip_router_interface import *
class SimpleIPRouteProviderCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.RouterProvider = RouterProvides(charm=self)
self.framework.observe(self.on.install, self._on_install)
self.framework.observe(self.RouterProvider.on.routing_table_updated, self._routing_table_updated)
self.framework.observe(self.on.get_routing_table_action, self._action_get_routing_table)
def _on_install(self, event: ops.InstallEvent):
self.unit.status = ops.ActiveStatus("Ready to Provide")
def _routing_table_updated(self, event: RoutingTableUpdatedEvent):
routing_table = event.routing_table
# Process the networks however you like
implement_networks(all_networks)
def _action_get_routing_table(self, event: ops.ActionEvent):
all_networks = self.RouterProvider.get_flattened_routing_table()
event.set_results({"msg": json.dumps(all_networks)})
if __name__ == "__main__": # pragma: nocover
ops.main(SimpleIPRouteProviderCharm) # type: ignore
```
### Requirer charm
This example requirer charm shows the two available actions as a host in the network:
* get the latest list of all networks available from the provider
* request a network to be assigned to the requirer charm
The ip-router requirer allows a foolproof, typechecked, secure and safe way to
interact with the router that handles validation and format of the network
request, so you can focus on more important things. The library also provides a
way to list out all of the available networks. This list is not cached, and comes
directly from the provider.
```python
import logging, json
import ops
from charms.ip_router_interface.v0.ip_router_interface import *
class SimpleIPRouteRequirerCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.RouterRequirer = RouterRequires(charm=self)
self.framework.observe(self.on.install, self._on_install)
self.framework.observe(self.on.ip_router_relation_joined, self._on_relation_joined)
self.framework.observe(self.RouterProvider.on.routing_table_updated, self._routing_table_updated)
self.framework.observe(self.on.get_all_networks_action, self._action_get_all_networks)
self.framework.observe(self.on.request_network_action, self._action_request_network)
def _on_install(self, event: ops.InstallEvent):
self.unit.status = ops.ActiveStatus("Ready to Provide")
def _on_relation_joined(self, event: ops.RelationJoinedEvent):
self.unit.status = ops.ActiveStatus("Ready to Require")
def _routing_table_updated(self, event: RoutingTableUpdatedEvent):
# Get and process all of the available networks when they're updated
all_networks = self.RouterRequirer.get_all_networks()
def _action_get_all_networks(self, event: ops.ActionEvent):
# Get and process all of the available networks any time you like
all_networks = self.RouterRequirer.get_all_networks()
event.set_results({"msg": json.dumps(all_networks)})
def _action_request_network(self, event: ops.ActionEvent):
# Request a new network as required in the required format
self.RouterRequirer.request_network(event.params["network"])
event.set_results({"msg": "ok"})
if __name__ == "__main__": # pragma: nocover
ops.main.main(SimpleIPRouteRequirerCharm) # type: ignore
```
You can relate both charms by running:
```bash
juju integrate <ip-router provider charm> <ip-router requirer charm>
```
""" # noqa: D405, D410, D411, D214, D416
# The unique Charmhub library identifier, never change it
LIBID = "8bed752769244d9ba01c61d5647683cf"
# Increment this major API version when introducing breaking changes
LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 2
from ipaddress import IPv4Address, IPv4Network
from typing import Dict, List, Union, TypeAlias
from ops.framework import Object, EventSource, EventBase, ObjectEvents
from ops.charm import CharmBase
from ops import RelationChangedEvent, RelationDepartedEvent, Relation
import logging, json
logger = logging.getLogger(__name__)
Network: TypeAlias = Dict[
str, # 'network' | 'gateway' | 'routes'
Union[
IPv4Address, # gateway, ex: '192.168.250.1'
IPv4Network, # network, ex: '192.168.250.0/24'
List[ # List of routes
Dict[
str, # 'destination' | 'gateway'
Union[
IPv4Address, # gateway, ex: '192.168.250.3'
IPv4Network, # destination, ex: '172.250.0.0/16'
],
]
],
],
]
RoutingTable: TypeAlias = Dict[
str, # Name of the application
List[Network], # All networks for this application
]
class RoutingTableUpdatedEvent(EventBase):
"""
Charm event for when a host registers a route to an existing interface in the router
"""
def __init__(self, handle, routing_table=None):
super().__init__(handle)
self.routing_table = routing_table
def snapshot(self):
return {"data": self.routing_table}
def restore(self, snapshot):
self.routing_table = snapshot["data"]
class RouterProviderCharmEvents(ObjectEvents):
routing_table_updated = EventSource(RoutingTableUpdatedEvent)
class RouterRequirerCharmEvents(ObjectEvents):
routing_table_updated = EventSource(RoutingTableUpdatedEvent)
def _validate_network(network_request: Network, existing_routing_table: RoutingTable):
"""Validates the network configuration created by the ip-router requirer
The requested network must have all of the required fields, the gateway
has to be located within the network, and all of the routes need to have
a path through the top level network. The requested network must also be
unassigned by the provider.
Args:
network_request:
An object of type `Network` that will be validated.
new_networks:
The rest of the networks to check if this one is mutually
exclusive to the rest of the subnets.
Raises:
ValueError:
Reasons could be that the gateway is not within the network,
there is no route to the destination, the network is already
taken or the same network is requested twice.
KeyError:
Missing required key
"""
if "gateway" not in network_request:
raise KeyError("Key 'gateway' not found.")
if "network" not in network_request:
raise KeyError("Key 'network' not found.")
gateway = IPv4Address(network_request.get("gateway"))
network = IPv4Network(network_request.get("network"))
if gateway not in network:
ValueError("Chosen gateway not within given network.")
for route in network_request.get("routes", []):
if "gateway" not in route:
raise KeyError("Key 'gateway' not found in route.")
if "destination" not in route:
raise KeyError("Key 'destination' not found in route.")
route_gateway = IPv4Address(route["gateway"])
if route_gateway not in network:
raise ValueError("There is no route to this destination from the network.")
for existing_network_list in existing_routing_table.values():
for existing_network in existing_network_list:
old_subnet = IPv4Network(existing_network["network"])
new_subnet = IPv4Network(network_request["network"])
if old_subnet.subnet_of(new_subnet) or old_subnet.supernet_of(new_subnet):
raise ValueError("This network has been defined in a previous entry.")
def _network_name_taken(name: str, relations: List[Relation]) -> bool:
count = 0
for relation in relations:
if name == relation.data[relation.app].get("network-name"):
count += 1
if count > 1:
logger.error(
"There are multiple relations with the name (%s). Please change one or provide a custom network name.",
name,
)
return True
return False
class RouterProvides(Object):
"""This class is used to manage the routing table of the router provider.
It's capabilities are to:
* Build a Routing Table from all of the databags of the requirers with their
declared networks.
* Synchronize the databags of all requiring units with the aforementioned
routing table.
* Send events indicating a change in this table.
Attributes:
charm:
The Charm object that instantiates this class.
relationship_name:
The name used for the relationship implementing the ip-router interface.
All requirers that integrate to this name are grouped into one routing table.
"ip-router" by default.
"""
on = RouterProviderCharmEvents()
def __init__(self, charm: CharmBase, relationship_name: str = "ip-router"):
super().__init__(charm, relationship_name)
self.charm = charm
self.relationship_name = relationship_name
self.framework.observe(
charm.on[relationship_name].relation_changed, self._router_relation_changed
)
self.framework.observe(
charm.on[relationship_name].relation_departed, self._router_relation_departed
)
def _router_relation_changed(self, event: RelationChangedEvent):
"""Resync the databags since there could have been a change in networks."""
if not self.charm.unit.is_leader():
return
self._sync_routing_tables()
new_table = self.get_flattened_routing_table()
self.on.routing_table_updated.emit({"networks": new_table})
def _router_relation_departed(self, event: RelationDepartedEvent):
"""If an application has completely departed the relation, remove it
from the routing table.
"""
if not self.charm.unit.is_leader():
return
self._sync_routing_tables()
def get_routing_table(self) -> RoutingTable:
"""Build the routing table from all of the related databags. Relations
that don't have missing or invalid network requests will be ignored.
"""
router_relations = self.model.relations[self.relationship_name]
final_routing_table: RoutingTable = {}
for relation in router_relations:
new_network_name = relation.data[relation.app].get("network-name", None)
new_network_request: List[Network] = json.loads(
relation.data[relation.app].get("networks", "{}")
)
if (
not new_network_name
or not new_network_request
or _network_name_taken(new_network_name, router_relations)
):
continue
final_routing_table[new_network_name] = []
for network in new_network_request:
try:
_validate_network(network, final_routing_table)
except (ValueError, KeyError) as e:
logger.error(
"Exception (%s) occurred with network %s. Skipping this entry.",
e.args[0],
network,
)
else:
final_routing_table[new_network_name].append(network)
logger.debug(
"Added (%s) from app:(%s) with relation-name:(%s)",
new_network_request,
relation.app.name,
new_network_name,
)
logger.debug("Generated rt: %s", final_routing_table)
return final_routing_table
def get_flattened_routing_table(self) -> List[Network]:
"""Returns a read-only routing table that's flattened to fit the specification.
The routing table is in the form of
{
app1_name: list_of_networks_1,
app2_name: list_of_networks_2,
...
}
A flattened table looks like
[
*list_of_networks_1,
*list_of_networks_2,
...
]
Returns:
A list of objects of type `Network`
"""
internal_routing_table = self.get_routing_table()
final_routing_table: List[Network] = []
for networks in internal_routing_table.values():
final_routing_table.extend(networks)
logger.debug("Flattened RT to %s", final_routing_table)
return final_routing_table
def _sync_routing_tables(self) -> None:
"""Syncs the internal routing table with all of the requirer's app databags"""
routing_table = self.get_flattened_routing_table()
for relation in self.model.relations[self.relationship_name]:
relation.data[self.charm.app].update({"networks": json.dumps(routing_table)})
logger.info("Resynchronized routing tables with %s", routing_table)
class RouterRequires(Object):
"""ip-router requirer class to be instantiated by charms that require routing
This class provides methods to request a new network, and read the available
network from the router providers. These should be used exclusively to
interact with the relation.
Attributes:
charm: The Charm object that instantiates this class.
"""
on = RouterRequirerCharmEvents()
def __init__(self, charm: CharmBase, relationship_name: str = "ip-router"):
super().__init__(charm, relationship_name)
self.charm = charm
self.relationship_name = relationship_name
self.framework.observe(
charm.on[relationship_name].relation_changed, self._router_relation_changed
)
def _router_relation_changed(self, event: RelationChangedEvent):
new_table = self.get_all_networks()
self.on.routing_table_updated.emit({"networks": new_table})
def request_network(self, networks: List[Network], custom_network_name: str = None) -> None:
"""Requests a new network interface from the ip-router provider
The interfaces must be valid according to `_network_is_valid`. Multiple
calls to this function will replace the previously requested networks,
so all of the networks required must be given with each call.
Arguments:
networks:
A list containing the desired networks of the type `Network`.
custom_network_name:
A string to use as the name of the network. Defaults to the relation
name.
Raises:
RuntimeError:
No ip-router relation exists yet or validation of one
or more of the networks failed.
"""
if not self.charm.unit.is_leader():
return
ip_router_relations = self.model.relations.get(self.relationship_name)
if len(ip_router_relations) == 0:
raise RuntimeError("No ip-router relation exists yet.")
for network_request in networks:
_validate_network(network_request, {"existing-networks": self.get_all_networks()})
for relation in ip_router_relations:
network_name = custom_network_name if custom_network_name else relation.name
relation.data[self.charm.app].update({"networks": json.dumps(networks)})
relation.data[self.charm.app].update({"network-name": network_name})
logger.debug(
"Requested new network from the routers %s",
str([r.name for r in ip_router_relations]),
)
def get_all_networks(self) -> List[Network]:
"""Fetches combined routing tables made available by ip-router providers
Returns:
A list of objects of type `Network`. This list contains networks
from all ip-router providers that are integrated with the charm.
"""
if not self.charm.unit.is_leader():
return
router_relations = self.model.relations.get(self.relationship_name)
all_networks = []
for relation in router_relations:
if networks := relation.data[relation.app].get("networks"):
for network in json.loads(networks):
try:
_validate_network(network, {"existing-networks": all_networks})
except (ValueError, KeyError) as e:
logger.warning(
"Malformed network detected in the databag:\nNetwork: (%s)\nError: (%s)",
network,
e.args[0],
)
else:
all_networks.append(network)
logger.debug(
f"Read networks from app: (%s) and relation: (%s)",
relation.app.name,
self.relationship_name,
)
return all_networks