|
| 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) |
0 commit comments