Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ controllers/cloud.redhat.com/version.txt
junit-eno.xml
/artifacts

# Python E2E tests
*.egg-info/
__pycache__/
.pytest_cache/
pytest.log

18 changes: 18 additions & 0 deletions tests/rh_eno_ci/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM registry.redhat.io/ubi9/python-312:9.7-1770654692

USER 0

ARG OC_VER=4.20.6

RUN curl -L https://mirror.openshift.com/pub/openshift-v4/clients/ocp/${OC_VER}/openshift-client-linux-${OC_VER}.tar.gz -o /tmp/openshift-client.tar.gz && \
mkdir -p /tmp/openshift-client && \
tar -xzf /tmp/openshift-client.tar.gz -C /tmp/openshift-client && \
install -m 0755 /tmp/openshift-client/oc /usr/local/bin/oc && \
install -m 0755 /tmp/openshift-client/kubectl /usr/local/bin/kubectl && \
rm -rf /tmp/openshift-client*

RUN dnf -y install git && dnf clean all && rm -rf /var/cache/dnf

ADD . /opt/app-root/src

RUN pip install --no-cache-dir -e .
40 changes: 40 additions & 0 deletions tests/rh_eno_ci/deployment/job.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
apiVersion: template.openshift.io/v1
kind: Template
metadata:
name: eno-e2e-tests
parameters:
- name: IMAGE_TAG
value: latest
- name: IMAGE
value: quay.io/redhat-services-prod/hcm-eng-prod-tenant/rh-eno-ci
- name: ENO_NAMESPACE
value: ephemeral-namespace-operator-system
objects:
- apiVersion: batch/v1
kind: Job
metadata:
name: eno-e2e-tests-${IMAGE_TAG}
namespace: ${ENO_NAMESPACE}
spec:
backoffLimit: 0
activeDeadlineSeconds: 900
template:
spec:
serviceAccountName: ephemeral-namespace-operator-controller-manager
restartPolicy: Never
containers:
- name: e2e
image: ${IMAGE}:${IMAGE_TAG}
imagePullPolicy: Always
env:
- name: TEST_NS
value: ${ENO_NAMESPACE}
command: ["pytest"]
args: ["-vv", "-s"]
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
44 changes: 44 additions & 0 deletions tests/rh_eno_ci/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "rh-eno-ci"
version = "0.1.0"
description = "Ephemeral Namespace Operator E2E test suite"
requires-python = ">=3.9"
dependencies = [
"pytest>=7.0.0",
"ocviapy",
"wait_for",
"PyYAML",
]

[project.optional-dependencies]
dev = [
"pytest-cov",
"ruff",
]

[tool.setuptools]
packages = ["rh_eno_ci", "rh_eno_ci.tests", "rh_eno_ci.resources"]

[tool.setuptools.package-data]
"rh_eno_ci.resources" = ["*.yaml"]

[tool.ruff]
include = ["rh_eno_ci/**/*.py"]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "W", "I"]

[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)s] %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
log_file = "pytest.log"
log_file_level = "DEBUG"
log_file_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
log_file_date_format = "%Y-%m-%d %H:%M:%S"
Empty file.
Empty file.
Empty file.
216 changes: 216 additions & 0 deletions tests/rh_eno_ci/rh_eno_ci/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import json
import logging
import os

import pytest
from ocviapy import oc
from wait_for import wait_for

logger = logging.getLogger(__name__)

DEFAULT_NAMESPACE = os.environ.get("TEST_NS", "ephemeral-namespace-operator-system")
E2E_PREFIX = "e2e-test-"


def oc_json(*args, **kwargs):
"""Run an oc command and return parsed JSON output."""
result = str(oc(*args, "-o", "json", _silent=True, **kwargs))
return json.loads(result)


def create_pool(name, size=2, clowdenv_spec=None, secret_source_ns=None):
"""Create a NamespacePool and return its name."""
pool = {
"apiVersion": "cloud.redhat.com/v1alpha1",
"kind": "NamespacePool",
"metadata": {"name": name},
"spec": {
"size": size,
"local": True,
"limitrange": {
"metadata": {"name": "resource-limits"},
"spec": {
"limits": [{
"type": "Container",
"default": {"cpu": "200m", "memory": "512Mi"},
"defaultRequest": {"cpu": "100m", "memory": "384Mi"},
}]
},
},
"resourcequotas": {
"items": [{
"metadata": {"name": "compute-resources"},
"spec": {"hard": {"pods": "10"}},
}]
},
},
}
if clowdenv_spec:
pool["spec"]["clowdenvironment"] = clowdenv_spec
if secret_source_ns:
pool["spec"]["defaultSecretSourceNamespace"] = secret_source_ns

oc("apply", "-f", "-", _in=json.dumps(pool))
logger.info("Created NamespacePool %s (size=%d)", name, size)
return name


def create_reservation(name, pool, duration="30m", requester="e2e-test"):
"""Create a NamespaceReservation and return its name."""
res = {
"apiVersion": "cloud.redhat.com/v1alpha1",
"kind": "NamespaceReservation",
"metadata": {"name": name},
"spec": {
"requester": requester,
"duration": duration,
"pool": pool,
},
}
oc("apply", "-f", "-", _in=json.dumps(res))
logger.info("Created NamespaceReservation %s (pool=%s, duration=%s)", name, pool, duration)
return name


def wait_for_pool_namespace(pool_name, timeout=240):
"""Wait for at least one Active namespace with label pool=<name>. Return its name."""
def _check():
try:
result = oc_json("get", "namespaces", "-l", f"pool={pool_name}")
for ns in result.get("items", []):
phase = ns.get("status", {}).get("phase")
env_status = ns.get("metadata", {}).get("annotations", {}).get("env-status", "")
if phase == "Active" and env_status != "deleting":
return ns["metadata"]["name"]
except Exception:
pass
return None

ns_name, _ = wait_for(_check, timeout=timeout, delay=5, fail_condition=None,
message=f"waiting for namespace in pool {pool_name}")
assert ns_name, f"timed out waiting for namespace in pool {pool_name}"
return ns_name


def wait_for_ready_pool_namespace(pool_name, timeout=240):
"""Wait for a ready namespace in the pool. Returns its name.

Combines namespace discovery and readiness into a single poll to avoid
races where the operator deletes and recreates namespaces.
"""
def _check():
try:
result = oc_json("get", "namespaces", "-l", f"pool={pool_name}")
for ns in result.get("items", []):
phase = ns.get("status", {}).get("phase")
env_status = ns.get("metadata", {}).get("annotations", {}).get("env-status", "")
if phase == "Active" and env_status == "ready":
return ns["metadata"]["name"]
except Exception:
pass
return None

ns_name, _ = wait_for(_check, timeout=timeout, delay=5, fail_condition=None,
message=f"waiting for ready namespace in pool {pool_name}")
assert ns_name, f"timed out waiting for ready namespace in pool {pool_name}"
return ns_name


def wait_for_namespace_ready(ns_name, timeout=240):
"""Wait for a namespace to have env-status=ready annotation."""
def _check():
try:
ns = oc_json("get", "namespace", ns_name)
annotations = ns.get("metadata", {}).get("annotations", {})
return annotations.get("env-status") == "ready"
except Exception:
return False

result, _ = wait_for(_check, timeout=timeout, delay=5,
message=f"waiting for namespace {ns_name} to become ready")
assert result, f"timed out waiting for namespace {ns_name} to become ready"


def wait_for_reservation_active(res_name, timeout=300):
"""Wait for a reservation to reach active state. Return the assigned namespace."""
def _check():
try:
res = oc_json("get", "namespacereservation", res_name)
status = res.get("status", {})
if status.get("state") == "active":
return status.get("namespace")
except Exception:
pass
return None

ns_name, _ = wait_for(_check, timeout=timeout, delay=5, fail_condition=None,
message=f"waiting for reservation {res_name} to become active")
assert ns_name, f"timed out waiting for reservation {res_name} to become active"
return ns_name


def wait_for_pool_ready(pool_name, expected_ready, timeout=240):
"""Wait for a pool's status.ready to reach expected count."""
def _check():
try:
pool = oc_json("get", "namespacepool", pool_name)
return pool.get("status", {}).get("ready", 0) == expected_ready
except Exception:
return False

result, _ = wait_for(_check, timeout=timeout, delay=5,
message=f"waiting for pool {pool_name} to have {expected_ready} ready")
assert result, f"timed out waiting for pool {pool_name} to have {expected_ready} ready"


def cleanup_pool(pool_name):
"""Delete a pool and all namespaces labeled with that pool name."""
logger.info("Cleaning up pool %s", pool_name)
try:
result = oc_json("get", "namespaces", "-l", f"pool={pool_name}")
for ns in result.get("items", []):
ns_name = ns["metadata"]["name"]
oc("delete", "namespace", ns_name, "--ignore-not-found=true", "--wait=false")
except Exception as e:
logger.info("Failed to list pool namespaces: %s", e)

oc("delete", "namespacepool", pool_name, "--ignore-not-found=true")


def cleanup_reservation(res_name):
"""Delete a reservation."""
logger.info("Cleaning up reservation %s", res_name)
oc("delete", "namespacereservation", res_name, "--ignore-not-found=true")


def cleanup_namespace(ns_name):
"""Delete a namespace."""
logger.info("Cleaning up namespace %s", ns_name)
oc("delete", "namespace", ns_name, "--ignore-not-found=true", "--wait=false")


@pytest.fixture(scope="session", autouse=True)
def cleanup_e2e_resources():
"""Safety net: clean up any e2e-test resources that survive test cleanup."""
yield
logger.info("AfterSuite: cleaning up stale e2e-test resources")

try:
result = oc_json("get", "namespacereservations")
for res in result.get("items", []):
name = res["metadata"]["name"]
if name.startswith(E2E_PREFIX):
cleanup_reservation(name)
except Exception:
pass

try:
result = oc_json("get", "namespacepools")
for pool in result.get("items", []):
name = pool["metadata"]["name"]
if name.startswith(E2E_PREFIX):
cleanup_pool(name)
except Exception:
pass

cleanup_namespace("e2e-test-secret-source")
Loading
Loading