Skip to content

Commit

Permalink
Merge pull request #5 from canonical/OPENG-2501
Browse files Browse the repository at this point in the history
Istio Beacon - Waypoint
  • Loading branch information
IbraAoad authored Aug 26, 2024
2 parents 6f87f41 + c01b21b commit 7f0aa90
Show file tree
Hide file tree
Showing 12 changed files with 712 additions and 34 deletions.
7 changes: 6 additions & 1 deletion .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ on:
branches:
- main

# The following configuration is temporarily pinned to this branch to enable Juju to restart hooks,
# which are disabled by default (https://github.com/charmed-kubernetes/actions-operator/blob/5c6377ed695d52b8a1693f07b7d641e245269123/src/bootstrap/index.ts#L205).
# Note: This causes integration tests to fail as charms lose connection to the Juju controller
# when added to the mesh, regaining connection only upon hook retry.
# TODO: Revert to main branch after #8 is resolved.
jobs:
pull-request:
name: PR
uses: canonical/observability/.github/workflows/charm-pull-request.yaml@main
uses: canonical/observability/.github/workflows/charm-pull-request.yaml@istio-test
secrets: inherit
19 changes: 19 additions & 0 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,22 @@ parts:
# Pydantic is not actually used by the lib but charmcraft install pydeps from libs even if
# they are not used.
- pydantic>2.0
plugin: charm
build-packages: [git]

config:
options:
ready-timeout:
type: int
default: 100
description: >
The maximum time (in seconds) to wait for the waypoint deployment to be
ready. This applies specifically to the deployment created for the Istio
waypoint controller. If the deployment does not become ready within this time,
charm will go into error state.
model-on-mesh:
type: boolean
default: false
description: >
Add this charm's model to the service mesh.
All charms in this model will automatically be added to the mesh.
6 changes: 6 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
ops ~= 2.5
pydantic>=2

# A collection of helpers and shared code for using Lightkube
# Code: https://github.com/canonical/lightkube-extensions
# Deps: charm
lightkube-extensions @ git+https://github.com/canonical/lightkube-extensions.git@main
232 changes: 215 additions & 17 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,236 @@
"""Istio Beacon Charm."""

import logging
import time

import ops
from lightkube.core.client import Client
from lightkube.core.exceptions import ApiError
from lightkube.generic_resource import create_namespaced_resource
from lightkube.models.meta_v1 import ObjectMeta
from lightkube.resources.apps_v1 import Deployment
from lightkube.resources.core_v1 import Namespace
from lightkube_extensions.batch import KubernetesResourceManager, create_charm_default_labels
from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus

from models import AllowedRoutes, IstioWaypointResource, IstioWaypointSpec, Listener, Metadata

logger = logging.getLogger(__name__)

RESOURCE_TYPES = {
"Gateway": create_namespaced_resource(
"gateway.networking.k8s.io", "v1", "Gateway", "gateways"
),
}

WAYPOINT_RESOURCE_TYPES = {RESOURCE_TYPES["Gateway"]}
WAYPOINT_LABEL = "istio-waypoint"


class IstioBeaconCharm(ops.CharmBase):
"""Charm the service."""

def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.start, self.on_start)

self._lightkube_field_manager: str = self.app.name
self._lightkube_client = None
self._managed_labels = f"{self.app.name}-{self.model.name}"
self.framework.observe(self.on.config_changed, self._on_config_changed)
self.framework.observe(self.on.remove, self._on_remove)
self.framework.observe(self.on["service-mesh"].relation_changed, self.on_mesh_changed)
self.framework.observe(self.on["service-mesh"].relation_broken, self.on_mesh_broken)

def on_start(self, _event):
"""Event handler for start."""
self.unit.status = ops.ActiveStatus()
def _on_config_changed(self, _):
"""Event handler for config changed."""
self._sync_all_resources()

def on_mesh_changed(self, _event):
def on_mesh_changed(self, _):
"""Event handler for service-mesh relation_changed."""
self.update_mesh()

def update_mesh(self):
"""Update the service mesh.
Reads all relations and any config to generate a kubernetes manifest. Then applies the
manifest.
"""
for relation in self.model.relations["service-mesh"]:
logger.error(relation.data[relation.app])
# Update the mesh
self.unit.status = ops.ActiveStatus()
self._sync_all_resources()

def on_mesh_broken(self, _):
"""Event handler for service-mesh relation_broken."""
self._sync_all_resources()

def _on_remove(self, _):
"""Event handler for remove."""
self._remove_labels()
krm = self._get_waypoint_resource_manager()
krm.delete()

@property
def lightkube_client(self):
"""Returns a lightkube client configured for this charm."""
if self._lightkube_client is None:
self._lightkube_client = Client(
namespace=self.model.name, field_manager=self._lightkube_field_manager
)
return self._lightkube_client

def _get_waypoint_resource_manager(self):
return KubernetesResourceManager(
labels=create_charm_default_labels(
self.app.name, self.model.name, scope=WAYPOINT_LABEL
),
resource_types=WAYPOINT_RESOURCE_TYPES, # pyright: ignore
lightkube_client=self.lightkube_client,
logger=logger,
)

def _is_waypoint_deployment_ready(self) -> bool:
"""Check if the deployment is ready after 10 attempts."""
timeout = int(self.config["ready-timeout"])
check_interval = 10
attempts = timeout // check_interval

for _ in range(attempts):
try:
deployment = self.lightkube_client.get(
Deployment,
name=f"{self._managed_labels}-waypoint",
namespace=self.model.name,
)
if (
deployment.status
and deployment.status.readyReplicas == deployment.status.replicas
):
return True
logger.info("Deployment not ready, retrying...")
except ApiError:
logger.info("Deployment not found, retrying...")

time.sleep(check_interval)

return False

def _is_waypoint_ready(self) -> bool:

if not self._is_waypoint_deployment_ready():
return False
return True

def _sync_all_resources(self):
if not self.unit.is_leader():
self.unit.status = BlockedStatus("Waypoint can only be provided on the leader unit.")
return
self.unit.status = MaintenanceStatus("Validating waypoint readiness")
self._sync_waypoint_resources()
if not self._is_waypoint_ready():
raise RuntimeError("Waypoint's k8s deployment not ready, is istio properly installed?")
self.unit.status = ActiveStatus()

def _construct_waypoint(self):
gateway = IstioWaypointResource(
metadata=Metadata(
name=f"{self._managed_labels}-waypoint",
namespace=self.model.name,
labels={"istio.io/waypoint-for": "service"},
),
spec=IstioWaypointSpec(
gatewayClassName="istio-waypoint",
listeners=[
Listener(
name="mesh",
port=15008,
protocol="HBONE",
allowedRoutes=AllowedRoutes(namespaces={"from": "All"}),
)
],
),
)
gateway_resource = RESOURCE_TYPES["Gateway"]
return gateway_resource(
metadata=ObjectMeta.from_dict(gateway.metadata.model_dump()),
spec=gateway.spec.model_dump(),
)

def _sync_waypoint_resources(self):
resources_list = []
krm = self._get_waypoint_resource_manager()
resource_to_append = self._construct_waypoint()
resources_list.append(resource_to_append)
krm.reconcile(resources_list)

if self.config["model-on-mesh"]:
self._add_labels()
else:
self._remove_labels()

def _get_namespace(self):
"""Retrieve the namespace object."""
try:
return self.lightkube_client.get(Namespace, self.model.name)
except ApiError as e:
logger.error(f"Error fetching namespace: {e}")
return None

def _patch_namespace(self, namespace):
"""Patch the namespace with updated labels."""
try:
self.lightkube_client.patch(Namespace, self.model.name, namespace)
except ApiError as e:
logger.error(f"Error patching namespace: {e}")

def _add_labels(self):
"""Add specific labels to the namespace."""
namespace = self._get_namespace()
if not namespace:
raise RuntimeError(f"Error fetching namespace: {namespace}")

# Ensure metadata is not None
if namespace.metadata is None:
namespace.metadata = ObjectMeta()

# Ensure labels are a dictionary even if they are initially None or not set
if namespace.metadata.labels is None: # pyright: ignore
namespace.metadata.labels = {} # pyright: ignore

existing_labels = namespace.metadata.labels # pyright: ignore
if (
existing_labels.get("istio.io/use-waypoint")
or existing_labels.get("istio.io/dataplane-mode")
) and existing_labels.get(
"charms.canonical.com/istio.io.waypoint.managed-by"
) != f"{self._managed_labels}":
logger.error(
f"Cannot add labels: Namespace '{self.model.name}' is already configured with Istio labels managed by another entity."
)
return

labels_to_add = {
"istio.io/use-waypoint": f"{self._managed_labels}-waypoint",
"istio.io/dataplane-mode": "ambient",
"charms.canonical.com/istio.io.waypoint.managed-by": f"{self._managed_labels}",
}

namespace.metadata.labels.update(labels_to_add) # pyright: ignore
self._patch_namespace(namespace)

def _remove_labels(self):
"""Remove specific labels from the namespace."""
namespace = self._get_namespace()
if not namespace:
raise RuntimeError(f"Error fetching namespace: {namespace}")

if namespace.metadata and namespace.metadata.labels:
if (
namespace.metadata.labels.get("charms.canonical.com/istio.io.waypoint.managed-by")
!= f"{self._managed_labels}"
):
logger.warning(
f"Cannot remove labels: Namespace '{self.model.name}' has Istio labels managed by another entity."
)
return

labels_to_remove = {
"istio.io/use-waypoint": None,
"istio.io/dataplane-mode": None,
"charms.canonical.com/istio.io.waypoint.managed-by": None,
}

namespace.metadata.labels.update(labels_to_remove)
self._patch_namespace(namespace)


if __name__ == "__main__":
Expand Down
48 changes: 48 additions & 0 deletions src/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3

# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.
"""This module defines Pydantic schemas for various resources used in the Kubernetes Gateway API."""

from typing import Dict, List, Optional

from pydantic import BaseModel


# Global metadata schema
class Metadata(BaseModel):
"""Global metadata schema for Kubernetes resources."""

name: str
namespace: str
labels: Optional[Dict[str, str]] = None
annotations: Optional[Dict[str, str]] = None


class AllowedRoutes(BaseModel):
"""AllowedRoutes defines namespaces from which traffic is allowed."""

namespaces: Dict[str, str]


class Listener(BaseModel):
"""Listener defines a port and protocol configuration."""

name: str
port: int
protocol: str
allowedRoutes: AllowedRoutes # noqa: N815


class IstioWaypointSpec(BaseModel):
"""IstioWaypointSpec defines the specification of a waypoint."""

gatewayClassName: str # noqa: N815
listeners: List[Listener]


class IstioWaypointResource(BaseModel):
"""IstioWaypointResource defines the structure of an waypoint Kubernetes resource."""

metadata: Metadata
spec: IstioWaypointSpec
Loading

0 comments on commit 7f0aa90

Please sign in to comment.