1111import logging
1212from time import sleep
1313from os import path
14+ from typing import Optional
1415
1516from kubernetes .dynamic .exceptions import NotFoundError
1617from 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 )
0 commit comments