Skip to content

Commit b9bdb3c

Browse files
authored
[minor] Allow installPlanApproval and startingCSV to be set on subscriptions (#206)
1 parent 40a92eb commit b9bdb3c

4 files changed

Lines changed: 741 additions & 13 deletions

File tree

src/mas/devops/olm.py

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import logging
1212
from time import sleep
1313
from os import path
14+
from typing import Optional
1415

1516
from kubernetes.dynamic.exceptions import NotFoundError
1617
from openshift.dynamic import DynamicClient
@@ -117,13 +118,17 @@ def getSubscription(dynClient: DynamicClient, namespace: str, packageName: str):
117118
return subscriptions.items[0]
118119

119120

120-
def applySubscription(dynClient: DynamicClient, namespace: str, packageName: str, packageChannel: str = None, catalogSource: str = None, catalogSourceNamespace: str = "openshift-marketplace", config: dict = None, installMode: str = "OwnNamespace"):
121+
def applySubscription(dynClient: DynamicClient, namespace: str, packageName: str, packageChannel: Optional[str] = None, catalogSource: Optional[str] = None, catalogSourceNamespace: str = "openshift-marketplace", config: Optional[dict] = None, installMode: str = "OwnNamespace", installPlanApproval: Optional[str] = None, startingCSV: Optional[str] = None):
121122
"""
122123
Create or update an operator subscription in a namespace.
123124
124125
Automatically detects default channel and catalog source from PackageManifest if not provided.
125126
Ensures an OperatorGroup exists before creating the subscription.
126127
128+
When installPlanApproval is set to "Manual" and a startingCSV is specified, this function will
129+
automatically approve the InstallPlan for the first-time installation to move to that startingCSV.
130+
Subsequent upgrades will still require manual approval.
131+
127132
Parameters:
128133
dynClient (DynamicClient): OpenShift Dynamic Client
129134
namespace (str): The namespace to create the subscription in
@@ -133,14 +138,20 @@ def applySubscription(dynClient: DynamicClient, namespace: str, packageName: str
133138
catalogSourceNamespace (str, optional): Namespace of the catalog source. Defaults to "openshift-marketplace".
134139
config (dict, optional): Additional subscription configuration. Defaults to None.
135140
installMode (str, optional): Install mode for the OperatorGroup. Defaults to "OwnNamespace".
141+
installPlanApproval (str, optional): Install plan approval mode ("Automatic" or "Manual"). Defaults to None.
142+
startingCSV (str, optional): The specific CSV version to install. When combined with Manual approval,
143+
the first InstallPlan to this CSV will be automatically approved. Required when installPlanApproval is "Manual". Defaults to None.
136144
137145
Returns:
138146
Subscription: The created or updated subscription resource
139147
140148
Raises:
141-
OLMException: If the package is not available in any catalog
149+
OLMException: If the package is not available in any catalog, or if installPlanApproval is "Manual" without a startingCSV
142150
NotFoundError: If resources cannot be created
143151
"""
152+
# Validate that startingCSV is provided when installPlanApproval is Manual
153+
if installPlanApproval == "Manual" and startingCSV is None:
154+
raise OLMException("When installPlanApproval is 'Manual', a startingCSV must be provided")
144155
if catalogSourceNamespace is None:
145156
catalogSourceNamespace = "openshift-marketplace"
146157

@@ -190,7 +201,9 @@ def applySubscription(dynClient: DynamicClient, namespace: str, packageName: str
190201
package_name=packageName,
191202
package_channel=packageChannel,
192203
catalog_name=catalogSource,
193-
catalog_namespace=catalogSourceNamespace
204+
catalog_namespace=catalogSourceNamespace,
205+
install_plan_approval=installPlanApproval,
206+
starting_csv=startingCSV
194207
)
195208
subscription = yaml.safe_load(renderedTemplate)
196209
subscriptionsAPI.apply(body=subscription, namespace=namespace)
@@ -199,6 +212,7 @@ def applySubscription(dynClient: DynamicClient, namespace: str, packageName: str
199212
logger.debug(f"Waiting for {packageName}.{namespace} InstallPlans")
200213
installPlanAPI = dynClient.resources.get(api_version="operators.coreos.com/v1alpha1", kind="InstallPlan")
201214

215+
# Use label selector to get InstallPlans (standard approach)
202216
installPlanResources = installPlanAPI.get(label_selector=labelSelector, namespace=namespace)
203217
while len(installPlanResources.items) == 0:
204218
installPlanResources = installPlanAPI.get(label_selector=labelSelector, namespace=namespace)
@@ -207,27 +221,114 @@ def applySubscription(dynClient: DynamicClient, namespace: str, packageName: str
207221
if len(installPlanResources.items) == 0:
208222
raise OLMException(f"Found 0 InstallPlans for {packageName}")
209223
elif len(installPlanResources.items) > 1:
210-
logger.warning(f"More than 1 InstallPlan found for {packageName}")
224+
logger.warning(f"More than 1 InstallPlan found for {packageName} using label selector")
225+
226+
# Select the InstallPlan to use
227+
installPlanResource = None
228+
229+
# Special handling for Manual approval with startingCSV
230+
if installPlanApproval == "Manual" and startingCSV is not None:
231+
logger.debug(f"Manual approval with startingCSV {startingCSV} - checking if label selector returned correct InstallPlan")
232+
233+
# Check if any of the InstallPlans from label selector match the startingCSV
234+
for plan in installPlanResources.items:
235+
csvNames = getattr(plan.spec, "clusterServiceVersionNames", [])
236+
logger.debug(f"InstallPlan {plan.metadata.name} (from label selector) contains CSVs: {csvNames}")
237+
if csvNames and startingCSV in csvNames:
238+
installPlanResource = plan
239+
logger.info(f"Found InstallPlan {plan.metadata.name} matching startingCSV {startingCSV} via label selector")
240+
break
241+
242+
# If no match found via label selector, search all InstallPlans owned by this subscription
243+
if installPlanResource is None:
244+
logger.warning(f"Label selector did not return InstallPlan matching startingCSV {startingCSV}")
245+
logger.debug(f"Searching all InstallPlans in {namespace} owned by subscription {name}")
246+
247+
allInstallPlans = installPlanAPI.get(namespace=namespace)
248+
for plan in allInstallPlans.items:
249+
# Check if this InstallPlan is owned by our subscription
250+
owner_refs = getattr(plan.metadata, 'ownerReferences', [])
251+
is_owned_by_subscription = any(
252+
ref.kind == "Subscription" and ref.name == name
253+
for ref in owner_refs
254+
)
255+
256+
if is_owned_by_subscription:
257+
csvNames = getattr(plan.spec, "clusterServiceVersionNames", [])
258+
logger.debug(f"InstallPlan {plan.metadata.name} (owned by subscription) contains CSVs: {csvNames}")
259+
if csvNames and startingCSV in csvNames:
260+
installPlanResource = plan
261+
logger.info(f"Found InstallPlan {plan.metadata.name} matching startingCSV {startingCSV} via subscription ownership")
262+
break
263+
264+
if installPlanResource is None:
265+
logger.warning(f"No InstallPlan found matching startingCSV {startingCSV}, using first from label selector")
266+
installPlanResource = installPlanResources.items[0]
211267
else:
212-
installPlanName = installPlanResources.items[0].metadata.name
213-
214-
# Wait for InstallPlan to complete
215-
logger.debug(f"Waiting for InstallPlan {installPlanName}")
216-
installPlanPhase = installPlanResources.items[0].status.phase
217-
while installPlanPhase != "Complete":
218-
installPlanResource = installPlanAPI.get(name=installPlanName, namespace=namespace)
219-
installPlanPhase = installPlanResource.status.phase
220-
sleep(30)
268+
# Standard case: use first InstallPlan from label selector
269+
installPlanResource = installPlanResources.items[0]
270+
271+
installPlanName = installPlanResource.metadata.name
272+
installPlanPhase = installPlanResource.status.phase
273+
274+
# If the InstallPlan for our startingCSV is already Complete, we're done
275+
if installPlanPhase == "Complete":
276+
logger.info(f"InstallPlan {installPlanName} for {startingCSV} is already Complete")
277+
else:
278+
# Wait for InstallPlan to complete
279+
logger.debug(f"Waiting for InstallPlan {installPlanName}")
280+
281+
# Track if we've already approved this install plan
282+
approved_manual_install = False
283+
284+
while installPlanPhase != "Complete":
285+
installPlanResource = installPlanAPI.get(name=installPlanName, namespace=namespace)
286+
installPlanPhase = installPlanResource.status.phase
287+
288+
# If InstallPlan requires approval and this is the first installation to startingCSV
289+
if installPlanPhase == "RequiresApproval" and not approved_manual_install:
290+
# Check if this is the first installation by verifying the CSV matches startingCSV
291+
if startingCSV is not None:
292+
csvName = getattr(installPlanResource.spec, "clusterServiceVersionNames", [])
293+
if csvName and startingCSV in csvName:
294+
logger.info(f"Approving InstallPlan {installPlanName} for first-time installation to {startingCSV}")
295+
# Patch the InstallPlan to approve it
296+
installPlanResource.spec.approved = True
297+
installPlanAPI.patch(
298+
body=installPlanResource,
299+
name=installPlanName,
300+
namespace=namespace,
301+
content_type="application/merge-patch+json"
302+
)
303+
approved_manual_install = True
304+
logger.info(f"InstallPlan {installPlanName} approved successfully")
305+
else:
306+
logger.debug(f"InstallPlan CSV {csvName} does not match startingCSV {startingCSV}, waiting for manual approval")
307+
else:
308+
logger.debug(f"No startingCSV specified, InstallPlan {installPlanName} requires manual approval")
309+
310+
sleep(30)
221311

222312
# Wait for Subscription to complete
223313
logger.debug(f"Waiting for Subscription {name} in {namespace}")
224314
while True:
225315
subscriptionResource = subscriptionsAPI.get(name=name, namespace=namespace)
226316
state = getattr(subscriptionResource.status, "state", None)
227317

318+
# When manual approval is used with startingCSV, the state will be "UpgradePending"
319+
# after the initial installation completes (indicating newer versions are available
320+
# but require manual approval). For automatic approval, the state will be "AtLatestKnown".
228321
if state == "AtLatestKnown":
229322
logger.debug(f"Subscription {name} in {namespace} reached state: {state}")
230323
return subscriptionResource
324+
elif state == "UpgradePending" and installPlanApproval == "Manual" and startingCSV is not None:
325+
# Verify the installed CSV matches the startingCSV
326+
installedCSV = getattr(subscriptionResource.status, "installedCSV", None)
327+
if installedCSV == startingCSV:
328+
logger.debug(f"Subscription {name} in {namespace} reached state: {state} with installedCSV: {installedCSV}")
329+
return subscriptionResource
330+
else:
331+
logger.debug(f"Subscription {name} in {namespace} state is {state} but installedCSV ({installedCSV}) does not match startingCSV ({startingCSV}), retrying...")
231332

232333
logger.debug(f"Subscription {name} in {namespace} not ready yet (state = {state}), retrying...")
233334
sleep(30)

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ spec:
99
channel: {{ package_channel }}
1010
source: {{ catalog_name }}
1111
sourceNamespace: {{ catalog_namespace }}
12+
{%- if install_plan_approval is not none %}
13+
installPlanApproval: {{ install_plan_approval }}
14+
{%- endif %}
15+
{%- if starting_csv is not none %}
16+
startingCSV: {{ starting_csv }}
17+
{%- endif %}
1218
{%- if subscription_config is not none %}
1319
config: {{ subscription_config }}
1420
{%- endif %}

test/src/test_olm.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,89 @@ def test_crud_with_config():
7979
olm.deleteSubscription(dynClient, namespace, "ibm-sls")
8080
olm.deleteSubscription(dynClient, namespace, "ibm-truststore-mgr")
8181
ocp.deleteNamespace(dynClient, namespace)
82+
83+
84+
def test_crud_with_manual_approval():
85+
"""
86+
Test that when installPlanApproval is Manual without a startingCSV,
87+
an OLMException is raised.
88+
"""
89+
namespace = "cli-fvt-3"
90+
91+
# This should raise an OLMException because Manual approval requires a startingCSV
92+
try:
93+
olm.applySubscription(
94+
dynClient,
95+
namespace,
96+
"ibm-sls",
97+
packageChannel="3.x",
98+
installPlanApproval="Manual"
99+
)
100+
# If we get here, the test should fail
101+
assert False, "Expected OLMException to be raised when installPlanApproval is Manual without startingCSV"
102+
except olm.OLMException as e:
103+
# Verify the error message is correct
104+
assert "When installPlanApproval is 'Manual', a startingCSV must be provided" in str(e)
105+
# Test passed - exception was raised as expected
106+
107+
108+
def test_crud_with_starting_csv():
109+
namespace = "cli-fvt-4"
110+
# Note: This test assumes a specific CSV version exists in the catalog
111+
# You may need to adjust the version based on what's available
112+
subscription = olm.applySubscription(
113+
dynClient,
114+
namespace,
115+
"ibm-sls",
116+
packageChannel="3.x",
117+
startingCSV="ibm-sls.v3.8.0"
118+
)
119+
assert subscription.metadata.name == "ibm-sls"
120+
assert subscription.metadata.namespace == namespace
121+
assert subscription.spec.startingCSV == "ibm-sls.v3.8.0"
122+
123+
# When we install the ibm-sls subscription OLM will automatically create the ibm-truststore-mgr
124+
# subscription, but when we delete the subscription, OLM will not automatically remove the latter
125+
olm.deleteSubscription(dynClient, namespace, "ibm-sls")
126+
olm.deleteSubscription(dynClient, namespace, "ibm-truststore-mgr")
127+
ocp.deleteNamespace(dynClient, namespace)
128+
129+
130+
def test_crud_with_manual_approval_and_starting_csv():
131+
"""
132+
Test that when installPlanApproval is Manual and startingCSV is specified,
133+
the first InstallPlan is automatically approved to reach the startingCSV.
134+
This allows the initial installation to proceed without manual intervention.
135+
136+
Note: With Manual approval and startingCSV, the subscription state will be
137+
"UpgradePending" after installation (indicating newer versions are available
138+
but require manual approval), not "AtLatestKnown".
139+
"""
140+
namespace = "cli-fvt-5"
141+
subscription = olm.applySubscription(
142+
dynClient,
143+
namespace,
144+
"ibm-sls",
145+
packageChannel="3.x",
146+
installPlanApproval="Manual",
147+
startingCSV="ibm-sls.v3.8.0"
148+
)
149+
assert subscription.metadata.name == "ibm-sls"
150+
assert subscription.metadata.namespace == namespace
151+
assert subscription.spec.installPlanApproval == "Manual"
152+
assert subscription.spec.startingCSV == "ibm-sls.v3.8.0"
153+
154+
# Verify that the subscription reached UpgradePending state
155+
# This confirms the InstallPlan was automatically approved and installed
156+
# UpgradePending indicates newer versions are available but require manual approval
157+
assert subscription.status.state == "UpgradePending"
158+
159+
# Verify the installed CSV matches the startingCSV
160+
installedCSV = subscription.status.installedCSV
161+
assert installedCSV == "ibm-sls.v3.8.0"
162+
163+
# When we install the ibm-sls subscription OLM will automatically create the ibm-truststore-mgr
164+
# subscription, but when we delete the subscription, OLM will not automatically remove the latter
165+
olm.deleteSubscription(dynClient, namespace, "ibm-sls")
166+
olm.deleteSubscription(dynClient, namespace, "ibm-truststore-mgr")
167+
ocp.deleteNamespace(dynClient, namespace)

0 commit comments

Comments
 (0)