Skip to content

Commit 2587c15

Browse files
authored
[minor] Add OLM support (#38)
1 parent 84e125f commit 2587c15

9 files changed

Lines changed: 305 additions & 11 deletions

File tree

.github/workflows/python-package.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
jobs:
77
build-package:
88
runs-on: ubuntu-latest
9+
concurrency:
10+
group: tests-on-ocp
11+
cancel-in-progress: false
912
steps:
1013
# 1. Initialize the build
1114
# -------------------------------------------------------------------------------------------
@@ -29,7 +32,15 @@ jobs:
2932
python-version: 3.11
3033

3134
- name: Build the Python package
35+
env:
36+
OCP_TOKEN: ${{ secrets.OCP_TOKEN }}
37+
OCP_SERVER: ${{ secrets.OCP_SERVER }}
3238
run: |
39+
kubectl config set-cluster my-cluster --server=$OCP_SERVER
40+
kubectl config set-credentials my-user --token=$OCP_TOKEN
41+
kubectl config set-context my-context --cluster=my-cluster --user=my-user --namespace=default
42+
kubectl config use-context my-context
43+
3344
sed -i "s#__version__ = \"100.0.0\"#__version__ = \"${{ env.VERSION_NOPREREL }}\"#g" ${GITHUB_WORKSPACE}/src/mas/devops/__init__.py
3445
cat ${GITHUB_WORKSPACE}/src/mas/devops/__init__.py
3546
python -m pip install --upgrade pip

.github/workflows/python-release.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ on:
55
jobs:
66
release-package:
77
runs-on: ubuntu-latest
8+
concurrency:
9+
group: tests-on-ocp
10+
cancel-in-progress: false
811
steps:
912
# 1. Initialize the build
1013
# -------------------------------------------------------------------------------------------
@@ -28,7 +31,15 @@ jobs:
2831
python-version: 3.11
2932

3033
- name: Build the Python package
34+
env:
35+
OCP_TOKEN: ${{ secrets.OCP_TOKEN }}
36+
OCP_SERVER: ${{ secrets.OCP_SERVER }}
3137
run: |
38+
kubectl config set-cluster my-cluster --server=$OCP_SERVER
39+
kubectl config set-credentials my-user --token=$OCP_TOKEN
40+
kubectl config set-context my-context --cluster=my-cluster --user=my-user --namespace=default
41+
kubectl config use-context my-context
42+
3243
sed -i "s#__version__ = \"100.0.0\"#__version__ = \"${{ env.VERSION_NOPREREL }}\"#g" ${GITHUB_WORKSPACE}/src/mas/devops/__init__.py
3344
cat ${GITHUB_WORKSPACE}/src/mas/devops/__init__.py
3445
python -m pip install --upgrade pip

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Developer Guide
22
===============================================================================
33

4+
Tests
5+
-------------------------------------------------------------------------------
6+
```
7+
pytest -o log_cli=true --log-cli-level=DEBUG test/src/test_olm.py::test_create_subscription
8+
```
9+
410

511
Detect Secrets
612
-------------------------------------------------------------------------------

src/mas/devops/ocp.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ def createNamespace(dynClient: DynamicClient, namespace: str) -> bool:
8282
return True
8383

8484

85+
def deleteNamespace(dynClient: DynamicClient, namespace: str) -> bool:
86+
"""
87+
Delete a namespace if it exists
88+
"""
89+
namespaceAPI = dynClient.resources.get(api_version="v1", kind="Namespace")
90+
try:
91+
namespaceAPI.delete(name=namespace)
92+
logger.debug(f"Namespace {namespace} deleted")
93+
except NotFoundError:
94+
logger.debug(f"Namespace {namespace} can not be deleted because it does not exist")
95+
return True
96+
97+
8598
def waitForCRD(dynClient: DynamicClient, crdName: str) -> bool:
8699
crdAPI = dynClient.resources.get(api_version="apiextensions.k8s.io/v1", kind="CustomResourceDefinition")
87100
maxRetries = 100
@@ -174,10 +187,9 @@ def crdExists(dynClient: DynamicClient, crdName: str) -> bool:
174187
logger.debug(f"CRD does not exist: {crdName}")
175188
return False
176189

190+
177191
# Assisted by WCA@IBM
178192
# Latest GenAI contribution: ibm/granite-8b-code-instruct
179-
180-
181193
def execInPod(core_v1_api: client.CoreV1Api, pod_name: str, namespace, command: list, timeout: int = 60) -> str:
182194
"""
183195
Executes a command in a Kubernetes pod and returns the standard output.

src/mas/devops/olm.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# *****************************************************************************
2+
# Copyright (c) 2024 IBM Corporation and other Contributors.
3+
#
4+
# All rights reserved. This program and the accompanying materials
5+
# are made available under the terms of the Eclipse Public License v1.0
6+
# which accompanies this distribution, and is available at
7+
# http://www.eclipse.org/legal/epl-v10.html
8+
#
9+
# *****************************************************************************
10+
11+
import logging
12+
from time import sleep
13+
from os import path
14+
15+
from kubernetes.dynamic.exceptions import NotFoundError
16+
from openshift.dynamic import DynamicClient
17+
from jinja2 import Environment, FileSystemLoader
18+
19+
import yaml
20+
21+
from .ocp import createNamespace
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
class OLMException(Exception):
27+
pass
28+
29+
30+
def getPackageManifest(dynClient: DynamicClient, packageName: str, catalogSourceNamespace: str = "openshift-marketplace"):
31+
# Assert that the PackageManifest exists
32+
# -----------------------------------------------------------------------------
33+
packagemanifestAPI = dynClient.resources.get(api_version="packages.operators.coreos.com/v1", kind="PackageManifest")
34+
try:
35+
manifestResource = packagemanifestAPI.get(name=packageName, namespace=catalogSourceNamespace)
36+
logger.info(f"Package Manifest Details: {catalogSourceNamespace}:{packageName} - Package is available from {manifestResource.status.catalogSource} (default channel is {manifestResource.status.defaultChannel})")
37+
except NotFoundError:
38+
logger.info(f"Package Manifest Details: {catalogSourceNamespace}:{packageName} - Package is not available")
39+
manifestResource = None
40+
return manifestResource
41+
42+
43+
def ensureOperatorGroupExists(dynClient: DynamicClient, env: Environment, namespace: str):
44+
# Create a new OperatorGroup if necessary
45+
operatorGroupsAPI = dynClient.resources.get(api_version="operators.coreos.com/v1", kind="OperatorGroup")
46+
operatorGroupList = operatorGroupsAPI.get(namespace=namespace)
47+
if len(operatorGroupList.items) == 0:
48+
logger.debug(f"Creating new OperatorGroup in namespace {namespace}")
49+
template = env.get_template("operatorgroup.yml.j2")
50+
renderedTemplate = template.render(
51+
name="operatorgroup",
52+
namespace=namespace
53+
)
54+
operatorGroup = yaml.safe_load(renderedTemplate)
55+
operatorGroupsAPI.apply(body=operatorGroup, namespace=namespace)
56+
else:
57+
logger.debug(f"An OperatorGroup already exists in namespace {namespace}")
58+
59+
60+
def applySubscription(dynClient: DynamicClient, namespace: str, packageName: str, packageChannel: str = None, catalogSource: str = None, catalogSourceNamespace: str = "openshift-marketplace", config: dict = None):
61+
"""
62+
Usage:
63+
createSubscription(dynClient, "testns1", "sub1", "ibm-sls") # use default channel, & auto-detect CatalogSource
64+
"""
65+
labelSelector = f"operators.coreos.com/{packageName}.{namespace}"
66+
templateDir = path.join(path.abspath(path.dirname(__file__)), "templates")
67+
env = Environment(
68+
loader=FileSystemLoader(searchpath=templateDir)
69+
)
70+
71+
if packageChannel is None or catalogSource is None:
72+
logger.debug("Getting PackageManifest to determine defaults")
73+
manifestResource = getPackageManifest(dynClient, packageName, catalogSourceNamespace)
74+
if manifestResource is None:
75+
raise OLMException(f"Package {packageName} is not available from any catalog in {catalogSourceNamespace}")
76+
77+
# Set defaults for optional parameters
78+
if packageChannel is None:
79+
logger.debug(f"Setting subscription channel based on PackageManifest: {manifestResource.status.defaultChannel}")
80+
packageChannel = manifestResource.status.defaultChannel
81+
if catalogSource is None:
82+
logger.debug(f"Setting subscription catalogSource based on PackageManifest: {manifestResource.status.catalogSource}")
83+
catalogSource = manifestResource.status.catalogSource
84+
85+
# Create the Namespace & OperatorGroup if necessary
86+
logger.debug(f"Setting up OperatorGroup in {namespace}")
87+
createNamespace(dynClient, namespace)
88+
ensureOperatorGroupExists(dynClient, env, namespace)
89+
90+
# Create (or update) the subscription
91+
subscriptionsAPI = dynClient.resources.get(api_version="operators.coreos.com/v1alpha1", kind="Subscription")
92+
93+
resources = subscriptionsAPI.get(label_selector=labelSelector, namespace=namespace)
94+
if len(resources.items) == 0:
95+
name = packageName
96+
logger.info(f"Creating new subscription {name} in {namespace}")
97+
elif len(resources.items) == 1:
98+
name = resources.items[0].metadata.name
99+
logger.info(f"Updating existing subscription {name} in {namespace}")
100+
else:
101+
raise OLMException(f"More than one subscription found in {namespace} for {packageName} ({len(resources.items)} subscriptions found)")
102+
103+
template = env.get_template("subscription.yml.j2")
104+
renderedTemplate = template.render(
105+
subscription_name=name,
106+
subscription_namespace=namespace,
107+
subscription_config=config,
108+
package_name=packageName,
109+
package_channel=packageChannel,
110+
catalog_name=catalogSource,
111+
catalog_namespace=catalogSourceNamespace
112+
)
113+
subscription = yaml.safe_load(renderedTemplate)
114+
subscriptionsAPI.apply(body=subscription, namespace=namespace)
115+
116+
# Wait for InstallPlan to be created
117+
logger.debug(f"Waiting for {packageName}.{namespace} InstallPlans")
118+
installPlanAPI = dynClient.resources.get(api_version="operators.coreos.com/v1alpha1", kind="InstallPlan")
119+
120+
installPlanResources = installPlanAPI.get(label_selector=labelSelector, namespace=namespace)
121+
while len(installPlanResources.items) == 0:
122+
installPlanResources = installPlanAPI.get(label_selector=labelSelector, namespace=namespace)
123+
sleep(30)
124+
125+
if len(installPlanResources.items) == 0:
126+
raise OLMException(f"Found 0 InstallPlans for {packageName}")
127+
elif len(installPlanResources.items) > 1:
128+
logger.warning(f"More than 1 InstallPlan found for {packageName}")
129+
else:
130+
installPlanName = installPlanResources.items[0].metadata.name
131+
132+
# Wait for InstallPlan to complete
133+
logger.debug(f"Waiting for InstallPlan {installPlanName}")
134+
installPlanPhase = installPlanResources.items[0].status.phase
135+
while installPlanPhase != "Complete":
136+
installPlanResource = installPlanAPI.get(name=installPlanName, namespace=namespace)
137+
installPlanPhase = installPlanResource.status.phase
138+
sleep(30)
139+
140+
# Wait for Subscription to complete
141+
logger.debug(f"Waiting for Subscription {name} in {namespace}")
142+
subscriptionResource = subscriptionsAPI.get(name=name, namespace=namespace)
143+
while subscriptionResource.status.state != "AtLatestKnown":
144+
subscriptionResource = subscriptionsAPI.get(name=name, namespace=namespace)
145+
sleep(30)
146+
147+
return subscriptionResource
148+
149+
150+
def deleteSubscription(dynClient: DynamicClient, namespace: str, packageName: str) -> None:
151+
labelSelector = f"operators.coreos.com/{packageName}.{namespace}"
152+
153+
# Find and delete the Subscription
154+
logger.debug(f"Deleting Subscription for {packageName} in {namespace}")
155+
subscriptionsAPI = dynClient.resources.get(api_version="operators.coreos.com/v1alpha1", kind="Subscription")
156+
_findAndDeleteResources(subscriptionsAPI, "Subscription", labelSelector, namespace)
157+
158+
# Find and delete the CSV
159+
logger.debug(f"Deleting CSV for {packageName}")
160+
csvAPI = dynClient.resources.get(api_version="operators.coreos.com/v1alpha1", kind="ClusterServiceVersion")
161+
_findAndDeleteResources(csvAPI, "CSV", labelSelector, namespace)
162+
163+
164+
def _findAndDeleteResources(api, resourceType: str, labelSelector: str, namespace: str):
165+
resources = api.get(label_selector=labelSelector, namespace=namespace)
166+
if resources.items == 0:
167+
logger.info(f"No matching {resourceType}s to delete")
168+
else:
169+
for item in resources.items:
170+
logger.info(f"Deleting {resourceType} {item.metadata.name}")
171+
api.delete(name=item.metadata.name, namespace=namespace)

src/mas/devops/tekton.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,12 @@ def installOpenShiftPipelines(dynClient: DynamicClient) -> bool:
5050
)
5151
template = env.get_template("subscription.yml.j2")
5252
renderedTemplate = template.render(
53-
pipelines_channel=defaultChannel,
54-
pipelines_source=catalogSource,
55-
pipelines_source_namespace=catalogSourceNamespace
53+
subscription_name="openshift-pipelines-operator",
54+
subscription_namespace="openshift-operators",
55+
package_name="openshift-pipelines-operator-rh",
56+
package_channel=defaultChannel,
57+
catalog_name=catalogSource,
58+
catalog_namespace=catalogSourceNamespace
5659
)
5760
subscription = yaml.safe_load(renderedTemplate)
5861
subscriptionsAPI.apply(body=subscription, namespace="openshift-operators")
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
apiVersion: operators.coreos.com/v1
3+
kind: OperatorGroup
4+
metadata:
5+
name: {{ name }}
6+
namespace: {{ namespace }}
7+
spec:
8+
targetNamespaces:
9+
- {{ namespace }}
10+
upgradeStrategy: Default

src/mas/devops/templates/subscription.yml.j2

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
apiVersion: operators.coreos.com/v1alpha1
33
kind: Subscription
44
metadata:
5-
name: openshift-pipelines-operator
6-
namespace: openshift-operators
5+
name: {{ subscription_name }}
6+
namespace: {{ subscription_namespace }}
77
spec:
8-
channel: {{pipelines_channel}}
9-
name: openshift-pipelines-operator-rh
10-
source: {{pipelines_source}}
11-
sourceNamespace: {{pipelines_source_namespace}}
8+
name: {{ package_name }}
9+
channel: {{ package_channel }}
10+
source: {{ catalog_name }}
11+
sourceNamespace: {{ catalog_namespace }}
12+
{%- if subscription_config is not none %}
13+
config: {{ subscription_config }}
14+
{%- endif %}

test/src/test_olm.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# *****************************************************************************
2+
# Copyright (c) 2024 IBM Corporation and other Contributors.
3+
#
4+
# All rights reserved. This program and the accompanying materials
5+
# are made available under the terms of the Eclipse Public License v1.0
6+
# which accompanies this distribution, and is available at
7+
# http://www.eclipse.org/legal/epl-v10.html
8+
#
9+
# *****************************************************************************
10+
11+
from openshift import dynamic
12+
from kubernetes import config
13+
from kubernetes.client import api_client
14+
15+
from mas.devops import olm, ocp
16+
17+
dynClient = dynamic.DynamicClient(
18+
api_client.ApiClient(configuration=config.load_kube_config())
19+
)
20+
21+
22+
def test_get_manifest():
23+
manifest = olm.getPackageManifest(dynClient, "ibm-sls")
24+
assert manifest is not None
25+
assert manifest.metadata.name == "ibm-sls"
26+
assert manifest.status.catalogSource == "ibm-operator-catalog"
27+
assert manifest.status.catalogSourceNamespace == "openshift-marketplace"
28+
assert manifest.status.catalogSourcePublisher == "IBM"
29+
assert manifest.status.defaultChannel == "3.x-stable"
30+
assert manifest.status.packageName == "ibm-sls"
31+
32+
33+
def test_get_manifest_none():
34+
manifest = olm.getPackageManifest(dynClient, "ibm-sls2")
35+
assert manifest is None
36+
37+
38+
def test_crud():
39+
namespace = "cli-fvt-1"
40+
subscription = olm.applySubscription(dynClient, namespace, "ibm-sls", packageChannel="3.x")
41+
assert subscription.metadata.name == "ibm-sls"
42+
assert subscription.metadata.namespace == namespace
43+
44+
# When we install the ibm-sls subscription OLM will automatically create the ibm-truststore-mgr
45+
# subscription, but when we delete the subscription, OLM will not automatically remove the latter
46+
olm.deleteSubscription(dynClient, namespace, "ibm-sls")
47+
olm.deleteSubscription(dynClient, namespace, "ibm-truststore-mgr")
48+
ocp.deleteNamespace(dynClient, namespace)
49+
50+
51+
def test_crud_with_config():
52+
namespace = "cli-fvt-2"
53+
# We don't need this, just want to test that it works
54+
testConfig = {
55+
"env": [
56+
{"name": "DUMMY_ENV_VAR", "value": "testing"}
57+
]
58+
}
59+
subscription = olm.applySubscription(dynClient, namespace, "ibm-sls", packageChannel="3.x", config=testConfig)
60+
assert subscription.metadata.name == "ibm-sls"
61+
assert subscription.metadata.namespace == namespace
62+
63+
# When we install the ibm-sls subscription OLM will automatically create the ibm-truststore-mgr
64+
# subscription, but when we delete the subscription, OLM will not automatically remove the latter
65+
olm.deleteSubscription(dynClient, namespace, "ibm-sls")
66+
olm.deleteSubscription(dynClient, namespace, "ibm-truststore-mgr")
67+
ocp.deleteNamespace(dynClient, namespace)

0 commit comments

Comments
 (0)