Skip to content

Commit

Permalink
Add Kubernetes CRD client and schemas (#33)
Browse files Browse the repository at this point in the history
* Add Kubernetes CRD client and schemas
  • Loading branch information
t-persson authored Oct 14, 2024
1 parent 32e8b89 commit c8d24db
Show file tree
Hide file tree
Showing 13 changed files with 787 additions and 2 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"eiffellib[rabbitmq]~=2.4",
"requests~=2.31",
"kubernetes~=26.1",
"pydantic~=2.1",
"pyyaml~=6.0",
"opentelemetry-api~=1.21",
"opentelemetry-semantic-conventions~=0.42b0",
Expand Down
9 changes: 7 additions & 2 deletions src/etos_lib/kubernetes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2020 Axis Communications AB.
# Copyright Axis Communications AB.
#
# For a full list of individual contributors, please see the commit history.
#
Expand All @@ -13,5 +13,10 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""ETOS library kubernetes helpers."""
"""ETOS Environment Provider Kubernetes module."""
from .jobs import Job
from .etos import Kubernetes, Resource
from .environment import Environment
from .environment_request import EnvironmentRequest
from .testrun import TestRun
from .provider import Provider
29 changes: 29 additions & 0 deletions src/etos_lib/kubernetes/environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright Axis Communications AB.
#
# For a full list of individual contributors, please see the commit history.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Environment custom resource manager ETOS."""
import logging
from .etos import Kubernetes, Resource


class Environment(Resource):
"""Environment handles the Environment custom Kubernetes resources."""

logger = logging.getLogger(__name__)

def __init__(self, client: Kubernetes):
"""Set up Kubernetes client."""
self.client = client.environments
self.namespace = client.namespace
29 changes: 29 additions & 0 deletions src/etos_lib/kubernetes/environment_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright Axis Communications AB.
#
# For a full list of individual contributors, please see the commit history.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Environment request custom resource manager ETOS."""
import logging
from .etos import Kubernetes, Resource


class EnvironmentRequest(Resource):
"""EnvironmentRequest handles the EnvironmentRequest custom Kubernetes resources."""

logger = logging.getLogger(__name__)

def __init__(self, client: Kubernetes):
"""Set up Kubernetes client."""
self.client = client.environment_requests
self.namespace = client.namespace
145 changes: 145 additions & 0 deletions src/etos_lib/kubernetes/etos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Copyright Axis Communications AB.
#
# For a full list of individual contributors, please see the commit history.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Kubernetes client for ETOS custom resources."""
import os
import logging
from pathlib import Path
from typing import Optional
from pydantic import BaseModel
from kubernetes import config
from kubernetes.client import api_client
from kubernetes.dynamic import DynamicClient
from kubernetes.dynamic.resource import Resource as DynamicResource, ResourceInstance
from kubernetes.dynamic.exceptions import NotFoundError

config.load_config()
NAMESPACE_FILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/namespace")


class NoNamespace(Exception):
"""NoNamespace exception is raised when ETOS could not determine the current namespace."""


class Resource:
"""Resource is the base resource client for ETOS custom resources.
This resource base class is used by our custom resources to, somewhat, mimic
the behavior of a built-in resource from Kubernetes. This means that we don't
do any error handling as that is not done in the built-in Kubernetes client.
While we do somewhat mimic the behavior we don't necessarily mimic the "API"
of the built-in resources. For example, we return boolean where the built-in
would return the Kubernetes API response. We do this because of how we typically
use Kubernetes in our services. If the Kubernetes API response is preferred
we can still use the client :obj:`DynamicResource` directly.
"""

client: DynamicResource
namespace: str = "default"

def get(self, name: str) -> Optional[ResourceInstance]:
"""Get a resource from Kubernetes by name."""
return self.client.get(name=name, namespace=self.namespace) # type: ignore

def delete(self, name: str) -> bool:
"""Delete a resource by name."""
if self.client.delete(name=name, namespace=self.namespace): # type: ignore
return True
return False

def create(self, model: BaseModel) -> bool:
"""Create a resource from a pydantic model."""
if self.client.create(body=model.model_dump(), namespace=self.namespace): # type: ignore
return True
return False

def exists(self, name: str) -> bool:
"""Test if a resource with name exists."""
try:
return self.get(name) is not None
except NotFoundError:
return False


class Kubernetes:
"""Kubernetes is a client for fetching ETOS custom resources from Kubernetes."""

__providers = None
__requests = None
__testruns = None
__environments = None
__namespace = None
logger = logging.getLogger(__name__)

def __init__(self, version="v1alpha1"):
"""Initialize a dynamic client with version."""
self.version = f"etos.eiffel-community.github.io/{version}"
self.__client = DynamicClient(api_client.ApiClient())

@property
def namespace(self) -> str:
"""Namespace returns the current namespace of the machine this code is running on."""
if self.__namespace is None:
if not NAMESPACE_FILE.exists():
self.logger.warning(
"Not running in Kubernetes? Namespace file not found: %s", NAMESPACE_FILE
)
etos_ns = os.getenv("ETOS_NAMESPACE")
if etos_ns:
self.logger.warning(
"Defauling to environment variable 'ETOS_NAMESPACE': %s", etos_ns
)
else:
self.logger.warning("ETOS_NAMESPACE environment variable not set!")
raise NoNamespace("Failed to determine Kubernetes namespace!")
self.__namespace = etos_ns
else:
self.__namespace = NAMESPACE_FILE.read_text(encoding="utf-8")
return self.__namespace

@property
def providers(self) -> DynamicResource:
"""Providers request returns a client for Provider resources."""
if self.__providers is None:
self.__providers = self.__client.resources.get(
api_version=self.version, kind="Provider"
)
return self.__providers

@property
def environment_requests(self) -> DynamicResource:
"""Environment requests returns a client for EnvironmentRequest resources."""
if self.__requests is None:
self.__requests = self.__client.resources.get(
api_version=self.version, kind="EnvironmentRequest"
)
return self.__requests

@property
def environments(self) -> DynamicResource:
"""Environments returns a client for Environment resources."""
if self.__environments is None:
self.__environments = self.__client.resources.get(
api_version=self.version, kind="Environment"
)
return self.__environments

@property
def testruns(self) -> DynamicResource:
"""Testruns returns a client for TestRun resources."""
if self.__testruns is None:
self.__testruns = self.__client.resources.get(api_version=self.version, kind="TestRun")
return self.__testruns
26 changes: 26 additions & 0 deletions src/etos_lib/kubernetes/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright Axis Communications AB.
#
# For a full list of individual contributors, please see the commit history.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Provider custom resource manager ETOS."""
from .etos import Kubernetes, Resource


class Provider(Resource):
"""Provider handles the Provider custom Kubernetes resources."""

def __init__(self, client: Kubernetes):
"""Set up Kubernetes client."""
self.client = client.providers
self.namespace = client.namespace
21 changes: 21 additions & 0 deletions src/etos_lib/kubernetes/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright Axis Communications AB.
#
# For a full list of individual contributors, please see the commit history.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""ETOS Kubernetes schemas."""
from .common import Metadata
from .environment import *
from .environment_request import *
from .testrun import *
from .provider import *
55 changes: 55 additions & 0 deletions src/etos_lib/kubernetes/schemas/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright Axis Communications AB.
#
# For a full list of individual contributors, please see the commit history.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Common schemas that are used between most kubernetes resources."""
from typing import Optional
from pydantic import BaseModel


class OwnerReference(BaseModel):
"""Owner reference describes the owner of a kubernetes resource."""

apiVersion: str
kind: str
name: str
uid: str
controller: Optional[bool]
blockOwnerDeletion: bool


class Metadata(BaseModel):
"""Metadata describes the metadata of a kubernetes resource."""

name: Optional[str] = None
generateName: Optional[str] = None
namespace: str = "default"
uid: Optional[str] = None
ownerReferences: list[OwnerReference] = []
labels: Optional[dict[str, str]] = None
annotations: Optional[dict[str, str]] = None


class Image(BaseModel):
"""Image is a container image representation."""

image: str
imagePullPolicy: str = "IfNotPresent"


class Retention(BaseModel):
"""Retention for ETOS testruns."""

failure: Optional[str] = None
success: Optional[str] = None
53 changes: 53 additions & 0 deletions src/etos_lib/kubernetes/schemas/environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright Axis Communications AB.
#
# For a full list of individual contributors, please see the commit history.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Models for the Environment resource."""
from typing import Optional
from pydantic import BaseModel
from .common import Metadata
from .testrun import Test, Suite


class EnvironmentSpec(BaseModel):
"""EnvironmentSpec is the specification of a Environment Kubernetes resource."""

name: str
suite_id: str
sub_suite_id: str
test_suite_started_id: str
artifact: str
context: str
priority: int = 1
test_runner: str
recipes: list[Test]
iut: dict
executor: dict
log_area: dict

@classmethod
def from_subsuite(cls, sub_suite: dict) -> "EnvironmentSpec":
"""Create environment spec from sub suite definition."""
sub_suite["recipes"] = Suite.tests_from_recipes(sub_suite.pop("recipes"))
spec = EnvironmentSpec(**sub_suite)
return spec


class Environment(BaseModel):
"""Environment Kubernetes resource."""

apiVersion: Optional[str] = "etos.eiffel-community.github.io/v1alpha1"
kind: Optional[str] = "Environment"
metadata: Metadata
spec: EnvironmentSpec
Loading

0 comments on commit c8d24db

Please sign in to comment.