Istio Ingress
| Channel | Revision | Published | Runs on |
|---|---|---|---|
| 1/stable | 33 | 26 Jun 2025 | |
| 1/candidate | 33 | 10 Jun 2025 | |
| 1/beta | 33 | 10 Jun 2025 | |
| 1/edge | 33 | 14 May 2025 | |
| 2/candidate | 50 | 08 Oct 2025 | |
| 2/edge | 52 | 03 Nov 2025 |
juju deploy istio-ingress-k8s --channel 1/stable
Deploy Kubernetes operators easily with Juju, the Universal Operator Lifecycle Manager. Need a Kubernetes cluster? Install MicroK8s to create a full CNCF-certified Kubernetes system in under 60 seconds.
Platform:
-
- Last updated
- Revision Library version 0.2
#!/usr/bin/env python3
# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
r"""# Interface Library for istio_ingress_route.
This library wraps relation endpoints for istio_ingress_route. The requirer of this
relation is any charm needing advanced ingress routing (multi-port, multi-protocol).
The provider is the istio-ingress-k8s charm.
## Getting Started
To get started using the library, you just need to fetch the library using `charmcraft`.
```shell
cd some-charm
charmcraft fetch-lib charms.istio_ingress_k8s.v0.istio_ingress_route
```
To use the library from the requirer side:
```yaml
requires:
ingress:
interface: istio_ingress_route
```
```python
from charms.istio_ingress_k8s.v0.istio_ingress_route import (
IstioIngressRouteRequirer,
IstioIngressRouteConfig,
Listener,
HTTPRoute,
GRPCRoute,
BackendRef,
ProtocolType,
HTTPMethod,
HTTPRouteMatch,
HTTPPathMatch,
PathMatchType,
GRPCMethodMatch,
GRPCRouteMatch,
to_gateway_protocol, # Helper for charm-side use
)
class MyCharm(CharmBase):
def __init__(self, *args):
# ...
self.ingress = IstioIngressRouteRequirer(
self,
relation_name="ingress",
)
self.framework.observe(
self.ingress.on.ready, self._on_ingress_ready
)
def _configure_ingress(self):
# Define listeners - names are auto-generated by the charm
http_listener = Listener(port=3200, protocol=ProtocolType.HTTP)
grpc_listener = Listener(port=9096, protocol=ProtocolType.GRPC)
config = IstioIngressRouteConfig(
model=self.model.name, # Requirer's namespace where services live
listeners=[http_listener, grpc_listener],
http_routes=[
HTTPRoute(
name="http-route",
listener=http_listener,
matches=[
HTTPRouteMatch(
path=HTTPPathMatch(type=HTTPPathMatchType.PathPrefix, value="/api"),
method=HTTPMethod.GET
)
],
backends=[BackendRef(service=self.app.name, port=3200)],
),
],
grpc_routes=[
GRPCRoute(
name="grpc-route",
listener=grpc_listener,
matches=[
GRPCRouteMatch(
method=GRPCMethodMatch(service="myapp.MyService", method="GetData")
)
],
backends=[BackendRef(service=self.app.name, port=9096)],
),
],
)
self.ingress.submit_config(config)
def _on_ingress_ready(self, event):
# Get the final external URL
scheme = "https" if self.ingress.tls_enabled else "http"
url = f"{scheme}://{self.ingress.external_host}"
# Use this URL for your application configuration
```
To use the library from the provider side (istio-ingress):
```yaml
provides:
istio-ingress-route:
interface: istio_ingress_route
```
```python
from charms.istio_ingress_k8s.v0.istio_ingress_route import IstioIngressRouteProvider
class IstioIngressCharm(CharmBase):
def __init__(self, *args):
# ...
self.istio_ingress_route = IstioIngressRouteProvider(
self,
external_host=self._external_host,
tls_enabled=self._is_tls_enabled(),
)
self.framework.observe(
self.istio_ingress_route.on.ready, self._handle_istio_ingress_route_ready
)
def _handle_istio_ingress_route_ready(self, event):
config = self.istio_ingress_route.get_config(event.relation)
if not config:
return
# Transform listeners based on TLS availability
is_tls_enabled = self._is_tls_enabled()
for listener in config.listeners:
gateway_protocol = to_gateway_protocol(listener.protocol, is_tls_enabled)
# Use gateway_protocol to create Gateway listeners
# Create HTTPRoutes and GRPCRoutes from config
```
"""
import logging
from abc import ABC
from enum import Enum
from typing import List, Optional
from ops.charm import CharmBase, CharmEvents, RelationEvent
from ops.framework import EventSource, Object, StoredState
from ops.model import Relation
from pydantic import BaseModel, Field, field_validator
# The unique Charmhub library identifier, never change it
LIBID = "3ae88161e5c34ba4b8392c93ef7d12ce"
# 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
log = logging.getLogger(__name__)
# -------------------------------------------------------------------
# Exceptions
# -------------------------------------------------------------------
class IstioIngressRouteException(RuntimeError):
"""Base class for exceptions raised by IstioIngressRoute."""
class UnauthorizedError(IstioIngressRouteException):
"""Raised when the unit needs the leader to perform some action."""
# -------------------------------------------------------------------
# Enums and Helper Functions
# -------------------------------------------------------------------
class ProtocolType(str, Enum):
"""Application-level protocol types.
Consumers specify the application protocol (HTTP or GRPC).
The istio-ingress charm automatically applies TLS encryption based on
certificate availability, upgrading HTTP→HTTPS and GRPC→GRPCS transparently.
Note: The Gateway API doesn't have GRPC as a distinct protocol type.
GRPC uses HTTP/2, so it maps to "HTTP" or "HTTPS" in Gateway listeners.
The difference between HTTP and gRPC traffic is expressed through the
route type (HTTPRoute vs GRPCRoute).
Examples:
>>> # Consumer specifies HTTP
>>> Listener(name="api", port=8080, protocol=ProtocolType.HTTnamespace=self.model.name, # Same namespace as ingressP)
# If TLS available: Gateway serves HTTPS on port 8080
# If TLS not available: Gateway serves HTTP on port 8080
>>> # Consumer specifies GRPC
>>> Listener(name="grpc", port=9090, protocol=ProtocolType.GRPC)
# If TLS available: Gateway serves HTTPS (HTTP/2 + TLS) on port 9090
# If TLS not available: Gateway serves HTTP on port 9090
"""
HTTP = "HTTP"
GRPC = "GRPC"
# TODO: Extend support to L4 protocols. See https://github.com/canonical/istio-ingress-k8s-operator/issues/114.
# TCP = "TCP"
# UDP = "UDP"
# NOTE: `protocol` arg will be needed if the library needs to support TCP, UDP routes.
def to_gateway_protocol(protocol: ProtocolType, tls_enabled: bool = False) -> str:
"""Map application protocol to Gateway API protocol.
The Gateway API doesn't have separate HTTP/gRPC protocol types.
Both use HTTP, with the difference being in the route type (HTTPRoute vs GRPCRoute).
Args:
protocol: Application-level protocol (HTTP or GRPC)
tls_enabled: Whether TLS termination should be applied
Returns:
Gateway API protocol string ("HTTP" or "HTTPS")
Examples:
>>> to_gateway_protocol(ProtocolType.HTTP, tls_enabled=False)
'HTTP'
>>> to_gateway_protocol(ProtocolType.HTTP, tls_enabled=True)
'HTTPS'
>>> to_gateway_protocol(ProtocolType.GRPC, tls_enabled=False)
'HTTP' # gRPC uses HTTP/2 (h2c when cleartext)
>>> to_gateway_protocol(ProtocolType.GRPC, tls_enabled=True)
'HTTPS' # gRPC uses HTTP/2 over TLS
"""
# HTTP and GRPC both use HTTP/2
if tls_enabled:
return "HTTPS"
else:
return "HTTP"
class HTTPMethod(str, Enum):
"""HTTP methods for route matching."""
GET = "GET"
POST = "POST"
PUT = "PUT"
DELETE = "DELETE"
PATCH = "PATCH"
HEAD = "HEAD"
OPTIONS = "OPTIONS"
CONNECT = "CONNECT"
TRACE = "TRACE"
class HTTPPathMatchType(str, Enum):
"""Path match types for HTTP routes."""
Exact = "Exact"
PathPrefix = "PathPrefix"
RegularExpression = "RegularExpression"
# -------------------------------------------------------------------
# Base Models
# -------------------------------------------------------------------
class Listener(BaseModel):
"""Gateway listener configuration.
Specify the application-level protocol (HTTP or GRPC).
The istio-ingress charm will automatically upgrade to TLS (HTTPS/GRPCS)
when certificates are available.
The listener name is automatically derived from port and protocol by the charm.
"""
port: int = Field(..., ge=1, le=65535, description="Port number")
protocol: ProtocolType = Field(..., description="Protocol type")
# TODO: uncomment the below when support is added for both wildcards and using subdomains
# hostname: Optional[str] = Field(None, description="Hostname binding for this listener")
@property
def name(self) -> str:
"""Get the listener name derived from protocol and port.
Returns:
Listener name in format: {protocol}-{port} (e.g., "http-8080", "grpc-9090")
"""
return f"{self.protocol.value.lower()}-{self.port}"
@property
def gateway_protocol(self) -> str:
"""Get the Gateway API protocol (cleartext).
This maps GRPC -> HTTP, since Gateway API doesn't have a GRPC protocol type.
Both HTTP and gRPC use HTTP/2; the difference is in the route type.
Returns:
Gateway API protocol string without TLS ("HTTP")
"""
return to_gateway_protocol(self.protocol, tls_enabled=False)
class BackendRef(BaseModel):
"""Reference to a backend service."""
service: str = Field(..., description="Service name (in same namespace)")
port: int = Field(..., ge=1, le=65535, description="Service port")
weight: Optional[int] = Field(None, ge=1, le=100, description="Traffic weight")
# -------------------------------------------------------------------
# Route Match Base Classes
# -------------------------------------------------------------------
class HTTPPathMatch(BaseModel):
"""Path matching configuration for HTTP routes."""
type: HTTPPathMatchType = Field(
default=HTTPPathMatchType.PathPrefix,
description="Type of path match (Exact, PathPrefix, or RegularExpression)"
)
value: str = Field(..., description="Path value to match")
class GRPCMethodMatch(BaseModel):
"""gRPC method matching configuration.
Matches gRPC methods in the format /service/method where:
- service can be a simple name (e.g., "MyService") or package-qualified (e.g., "package.MyService")
- method is the RPC method name (optional - if omitted, matches all methods on the service)
Examples:
>>> GRPCMethodMatch(service="com.example.UserService")
# Matches all methods on /com.example.UserService
>>> GRPCMethodMatch(service="com.example.UserService", method="GetUser")
# Matches only /com.example.UserService/GetUser
>>> GRPCMethodMatch(service="UserService", method="CreateUser")
# Matches /UserService/CreateUser
"""
service: str = Field(..., description="gRPC service name (e.g., 'package.Service')")
method: Optional[str] = Field(None, description="gRPC method name (e.g., 'GetUser'). If omitted, matches all methods on the service")
class _RouteMatch(BaseModel, ABC):
"""Base class for route match conditions."""
headers: Optional[dict] = Field(None, description="Header matches")
class HTTPRouteMatch(_RouteMatch):
"""Match conditions for HTTP routes."""
path: Optional[HTTPPathMatch] = Field(None, description="Path match configuration")
method: Optional[HTTPMethod] = Field(None, description="HTTP method")
class GRPCRouteMatch(_RouteMatch):
"""Match conditions for gRPC routes."""
method: Optional[GRPCMethodMatch] = Field(None, description="gRPC method match configuration")
# -------------------------------------------------------------------
# Route Base Classes
# -------------------------------------------------------------------
class _Route(BaseModel, ABC):
"""Base class for all routes."""
name: str = Field(..., description="Route name")
listener: Listener = Field(..., description="Listener this route binds to")
backends: List[BackendRef] = Field(..., description="Backend services")
@property
def protocol(self) -> ProtocolType:
"""Protocol type - overridden in subclasses."""
raise NotImplementedError
class _L7Route(_Route, ABC):
"""Base class for Layer 7 routes."""
hostnames: Optional[List[str]] = Field(None, description="Hostnames to match")
# -------------------------------------------------------------------
# Concrete Route Classes
# -------------------------------------------------------------------
class HTTPRoute(_L7Route):
"""HTTP route configuration."""
matches: Optional[List[HTTPRouteMatch]] = Field(None, description="HTTP match rules")
@property
def protocol(self) -> ProtocolType:
"""Protocol type for HTTP routes."""
return ProtocolType.HTTP
class GRPCRoute(_L7Route):
"""gRPC route configuration."""
matches: Optional[List[GRPCRouteMatch]] = Field(None, description="gRPC match rules")
@property
def protocol(self) -> ProtocolType:
"""Protocol type for gRPC routes."""
return ProtocolType.GRPC
# -------------------------------------------------------------------
# Main Config
# -------------------------------------------------------------------
class IstioIngressRouteConfig(BaseModel):
"""Complete configuration for istio-ingress-route."""
model: str = Field(..., description="The model (namespace) where backend services live")
listeners: List[Listener] = Field(default_factory=list)
http_routes: List[HTTPRoute] = Field(default_factory=list)
grpc_routes: List[GRPCRoute] = Field(default_factory=list)
# -------------------------------------------------------------------
# Events
# -------------------------------------------------------------------
class IstioIngressRouteProviderReadyEvent(RelationEvent):
"""Event emitted when istio-ingress is ready to provide ingress for a routed unit."""
class IstioIngressRouteProviderDataRemovedEvent(RelationEvent):
"""Event emitted when a routed ingress relation is removed."""
class IstioIngressRouteRequirerReadyEvent(RelationEvent):
"""Event emitted when a unit requesting ingress has provided all data."""
class IstioIngressRouteRequirerEvents(CharmEvents):
"""Container for IstioIngressRouteRequirer events."""
ready = EventSource(IstioIngressRouteRequirerReadyEvent)
class IstioIngressRouteProviderEvents(CharmEvents):
"""Container for IstioIngressRouteProvider events."""
ready = EventSource(IstioIngressRouteProviderReadyEvent)
data_removed = EventSource(IstioIngressRouteProviderDataRemovedEvent)
# -------------------------------------------------------------------
# Provider
# -------------------------------------------------------------------
class IstioIngressRouteProvider(Object):
"""Implementation of the provider of istio_ingress_route.
This will be owned by the istio-ingress charm.
The main idea is that istio-ingress will observe the `ready` event and, upon
receiving it, will fetch the config from the requirer's application databag,
apply it (create Gateway listeners and Routes), and update its own app databag
to let the requirer know that the ingress is ready.
"""
on = IstioIngressRouteProviderEvents() # pyright: ignore
_stored = StoredState()
def __init__(
self,
charm: CharmBase,
relation_name: str = "istio-ingress-route",
external_host: str = "",
*,
tls_enabled: bool = False,
):
"""Constructor for IstioIngressRouteProvider.
Args:
charm: The charm that is instantiating the instance.
relation_name: The name of the relation to bind to
(defaults to "istio-ingress-route").
external_host: The external host.
tls_enabled: Whether TLS is enabled on the gateway.
"""
super().__init__(charm, relation_name)
self._stored.set_default(external_host=None, tls_enabled=None)
self._charm = charm
self._relation_name = relation_name
if (
self._stored.external_host != external_host # pyright: ignore
or self._stored.tls_enabled != tls_enabled # pyright: ignore
):
# If istio-ingress endpoint details changed, update
self.update_ingress_address(external_host=external_host, tls_enabled=tls_enabled)
self.framework.observe(
self._charm.on[relation_name].relation_changed, self._on_relation_changed
)
self.framework.observe(
self._charm.on[relation_name].relation_broken, self._on_relation_broken
)
@property
def external_host(self) -> str:
"""Return the external host set by istio-ingress, if any."""
self._update_stored()
return self._stored.external_host or "" # type: ignore
@property
def tls_enabled(self) -> bool:
"""Return whether TLS is enabled on the gateway."""
self._update_stored()
return self._stored.tls_enabled or False # type: ignore
@property
def relations(self):
"""The list of Relation instances associated with this endpoint."""
return list(self._charm.model.relations[self._relation_name])
def _update_stored(self) -> None:
"""Ensure that the stored data is up-to-date."""
if not self._charm.unit.is_leader():
return
for relation in self._charm.model.relations[self._relation_name]:
if not relation.app:
self._stored.external_host = ""
self._stored.tls_enabled = False
return
external_host = relation.data[relation.app].get("external_host", "")
self._stored.external_host = (
external_host or self._stored.external_host # pyright: ignore
)
tls_enabled_str = relation.data[relation.app].get("tls_enabled", "False")
self._stored.tls_enabled = tls_enabled_str == "True"
def _on_relation_changed(self, event: RelationEvent):
if self.is_ready(event.relation):
self.update_ingress_address()
self.on.ready.emit(relation=event.relation, app=event.relation.app)
def _on_relation_broken(self, event: RelationEvent):
self.on.data_removed.emit(relation=event.relation, app=event.relation.app)
def update_ingress_address(
self, *, external_host: Optional[str] = None, tls_enabled: Optional[bool] = None
):
"""Ensure that requirers know the external host for istio-ingress."""
if not self._charm.unit.is_leader():
return
for relation in self._charm.model.relations[self._relation_name]:
relation.data[self._charm.app]["external_host"] = external_host or self.external_host
tls_value = tls_enabled if tls_enabled is not None else self.tls_enabled
relation.data[self._charm.app]["tls_enabled"] = str(tls_value)
# We first attempt to write relation data (which may raise) and only then update stored
# state.
self._stored.external_host = external_host
self._stored.tls_enabled = tls_enabled
def wipe_ingress_data(self, relation: Relation):
"""Clear ingress data from relation.
This removes the external_host and tls_enabled fields from the provider's
application databag for the given relation. This is typically used when
route conflicts are detected or when the ingress should no longer be available.
Args:
relation: The relation to clear data from
"""
if not self._charm.unit.is_leader():
log.debug("wipe_ingress_data: not leader, skipping")
return
try:
relation.data[self._charm.app].pop("external_host", None)
relation.data[self._charm.app].pop("tls_enabled", None)
except Exception as e:
log.warning(
f"Error {e} clearing ingress data for relation {relation.name}. "
"This may be a ghost of a dead relation."
)
def is_ready(self, relation: Relation) -> bool:
"""Whether IstioIngressRoute is ready on this relation.
Returns True when the remote app shared the config; False otherwise.
"""
if not relation.app or not relation.data[relation.app]:
return False
return "config" in relation.data[relation.app]
def get_config(self, relation: Relation) -> Optional[IstioIngressRouteConfig]:
"""Retrieve the config published by the remote application."""
if not self.is_ready(relation):
return None
config_json = relation.data[relation.app].get("config")
if not config_json:
return None
try:
return IstioIngressRouteConfig.model_validate_json(config_json)
except Exception as e:
log.error(f"Failed to parse config from {relation}: {e}")
return None
# -------------------------------------------------------------------
# Requirer
# -------------------------------------------------------------------
class IstioIngressRouteRequirer(Object):
"""Handles the requirer side of the istio-ingress-route interface.
This class provides an API for publishing routing configurations
to the istio-ingress charm through the `istio-ingress-route` relation.
"""
on = IstioIngressRouteRequirerEvents() # pyright: ignore
_stored = StoredState()
def __init__(
self,
charm: CharmBase,
relation_name: str = "ingress",
):
"""Constructor for IstioIngressRouteRequirer.
Args:
charm: The charm that is instantiating the instance.
relation_name: The name of the relation to bind to (defaults to "ingress").
"""
super().__init__(charm, relation_name)
self._stored.set_default(external_host=None, tls_enabled=None)
self._charm = charm
self._relation_name = relation_name
self.framework.observe(
self._charm.on[relation_name].relation_changed, self._on_relation_changed
)
self.framework.observe(
self._charm.on[relation_name].relation_broken, self._on_relation_broken
)
@property
def external_host(self) -> str:
"""Return the external host set by istio-ingress, if any."""
self._update_stored()
return self._stored.external_host or "" # type: ignore
@property
def tls_enabled(self) -> bool:
"""Return whether TLS is enabled on the gateway."""
self._update_stored()
return self._stored.tls_enabled or False # type: ignore
def _update_stored(self) -> None:
"""Ensure that the stored host is up-to-date."""
if not self._charm.unit.is_leader():
return
for relation in self._charm.model.relations[self._relation_name]:
if not relation.app:
self._stored.external_host = ""
self._stored.tls_enabled = False
return
external_host = relation.data[relation.app].get("external_host", "")
self._stored.external_host = (
external_host or self._stored.external_host # pyright: ignore
)
tls_enabled_str = relation.data[relation.app].get("tls_enabled", "False")
self._stored.tls_enabled = tls_enabled_str == "True"
def _on_relation_changed(self, event: RelationEvent) -> None:
"""Update StoredState with external_host and other information from istio-ingress."""
self._update_stored()
if self._charm.unit.is_leader():
self.on.ready.emit(relation=event.relation, app=event.relation.app)
def _on_relation_broken(self, event: RelationEvent) -> None:
"""On RelationBroken, clear the stored data if set and emit an event."""
self._stored.external_host = ""
self._stored.tls_enabled = False
if self._charm.unit.is_leader():
self.on.ready.emit(relation=event.relation, app=event.relation.app)
def is_ready(self) -> bool:
"""Is the IstioIngressRouteRequirer ready to submit data?"""
return len(self._charm.model.relations[self._relation_name]) > 0
def submit_config(self, config: IstioIngressRouteConfig):
"""Submit an ingress configuration to istio-ingress.
This method publishes routing configuration data to the
`istio-ingress-route` relation.
Args:
config: The IstioIngressRouteConfig to submit.
Raises:
UnauthorizedError: If the unit is not the leader.
"""
if not self._charm.unit.is_leader():
raise UnauthorizedError()
relations = self._charm.model.relations[self._relation_name]
if not relations:
log.warning(f"No relations found for {self._relation_name}")
return
for relation in relations:
app_databag = relation.data[self._charm.app]
# Serialize to JSON using Pydantic v2
app_databag["config"] = config.model_dump_json()