Skip to content

Commit 8021c67

Browse files
dixitgsathwaraDixit SathwaraIanBoden
authored
[minor] Add issuer_kind param and pre-install function (#261)
Co-authored-by: Dixit Sathwara <Dixit.Sathwara1@ibm.com> Co-authored-by: Ian Boden <82514609+IanBoden@users.noreply.github.com>
1 parent 6f47d28 commit 8021c67

2 files changed

Lines changed: 368 additions & 0 deletions

File tree

src/mas/devops/pre_install.py

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
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+
import yaml
13+
14+
from os import path, listdir
15+
16+
from kubernetes import client as k8s_client
17+
from openshift.dynamic import DynamicClient
18+
from jinja2 import Environment
19+
20+
logger = logging.getLogger(__name__)
21+
22+
DEFAULT_PREINSTALL_MAS_RBAC_ROOT = "/opt/app-root/rbac"
23+
24+
25+
def _validate_selected_apps(selectedApps: list[str] | None) -> set[str]:
26+
if not selectedApps:
27+
return set()
28+
29+
validApps = {
30+
"core",
31+
"aiservice",
32+
"arcgis",
33+
"facilities",
34+
"iot",
35+
"manage",
36+
"monitor",
37+
"optimizer",
38+
"predict",
39+
"visualinspection"
40+
}
41+
42+
validatedApps = set()
43+
for app in selectedApps:
44+
if app not in validApps:
45+
raise ValueError(f"Unsupported selected app: {app}")
46+
validatedApps.add(app)
47+
48+
return validatedApps
49+
50+
51+
def _get_selected_operator_dirs(selectedApps: set[str]) -> set[str]:
52+
53+
appToOperatorDir = {
54+
"core": "ibm-mas",
55+
"aiservice": "ibm-aiservice",
56+
"arcgis": "ibm-mas-arcgis",
57+
"facilities": "ibm-mas-facilities",
58+
"iot": "ibm-mas-iot",
59+
"manage": "ibm-mas-manage",
60+
"monitor": "ibm-mas-monitor",
61+
"optimizer": "ibm-mas-optimizer",
62+
"predict": "ibm-mas-predict",
63+
"visualinspection": "ibm-mas-visualinspection"
64+
}
65+
66+
return {appToOperatorDir[app] for app in selectedApps}
67+
68+
69+
def _should_apply_preinstall_mas_rbac_file(fileName: str, permissionMode: str) -> bool:
70+
lowerName = path.basename(fileName).lower()
71+
72+
if lowerName == "kustomization.yaml":
73+
return False
74+
75+
if not (lowerName.endswith(".yml") or lowerName.endswith(".yaml")):
76+
return False
77+
78+
# TODO: Sort out this openshift-ingress exception properly.
79+
# For now, always apply this manifest in any permission mode.
80+
if lowerName == "role-essential-core-entitymgr-suite-openshift-ingress.yaml":
81+
return True
82+
83+
if permissionMode == "cluster":
84+
return lowerName.startswith("cluster-role-")
85+
86+
if permissionMode == "namespaced":
87+
return lowerName.startswith("role-non-essential-")
88+
89+
return False
90+
91+
92+
def _collect_preinstall_mas_rbac_files_from_source(
93+
sourceOperatorsRoot: str,
94+
masVersion: str,
95+
permissionMode: str,
96+
operatorNames: set[str] | None = None
97+
) -> list[str]:
98+
if not path.isdir(sourceOperatorsRoot):
99+
logger.debug(f"Skipping missing RBAC source root {sourceOperatorsRoot}")
100+
return []
101+
102+
if operatorNames is None:
103+
operatorNames = {
104+
operatorName for operatorName in listdir(sourceOperatorsRoot)
105+
if path.isdir(path.join(sourceOperatorsRoot, operatorName))
106+
}
107+
108+
manifestFiles = []
109+
for operatorName in sorted(operatorNames):
110+
operatorRoot = path.join(sourceOperatorsRoot, operatorName)
111+
if not path.isdir(operatorRoot):
112+
logger.debug(f"Skipping missing operator root {operatorRoot}")
113+
continue
114+
115+
versionDir = path.join(operatorRoot, "rbac", masVersion)
116+
if not path.isdir(versionDir):
117+
logger.debug(f"Skipping missing RBAC version directory {versionDir}")
118+
continue
119+
120+
for manifestName in sorted(listdir(versionDir)):
121+
manifestFile = path.join(versionDir, manifestName)
122+
if not path.isfile(manifestFile):
123+
continue
124+
125+
if _should_apply_preinstall_mas_rbac_file(manifestName, permissionMode):
126+
manifestFiles.append(manifestFile)
127+
128+
return manifestFiles
129+
130+
131+
def _discover_preinstall_mas_rbac_files(
132+
rbacRootDir: str | None,
133+
masVersion: str,
134+
permissionMode: str,
135+
selectedApps: set[str]
136+
) -> list[str]:
137+
if not rbacRootDir:
138+
rbacRootDir = DEFAULT_PREINSTALL_MAS_RBAC_ROOT
139+
140+
# Due to ingresscontroller role we need to apply the preinstall RBAC for the minimal permission mode
141+
# if permissionMode == "minimal":
142+
# return []
143+
144+
selectedOperatorDirs = _get_selected_operator_dirs(selectedApps)
145+
146+
sourceRoots = [
147+
(
148+
path.join(rbacRootDir, "maximo-operator-catalog", "operators"),
149+
selectedOperatorDirs
150+
),
151+
(
152+
path.join(rbacRootDir, "openshift-platform", "operators"),
153+
None
154+
)
155+
]
156+
157+
manifestFiles = []
158+
for sourceRoot, operatorNames in sourceRoots:
159+
manifestFiles.extend(
160+
_collect_preinstall_mas_rbac_files_from_source(
161+
sourceOperatorsRoot=sourceRoot,
162+
masVersion=masVersion,
163+
permissionMode=permissionMode,
164+
operatorNames=operatorNames
165+
)
166+
)
167+
168+
return list(dict.fromkeys(manifestFiles))
169+
170+
171+
def _get_preinstall_mas_rbac_namespaces(masInstanceId: str, permissionMode: str, selectedApps: set[str]) -> set[str]:
172+
173+
# Due to ingresscontroller role we need to apply the preinstall RBAC for the minimal permission mode
174+
# if permissionMode == "minimal":
175+
# return set()
176+
177+
if permissionMode == "cluster":
178+
return set()
179+
180+
namespaces = {f"mas-{masInstanceId}-core"}
181+
182+
appNamespaces = {
183+
"aiservice": f"mas-{masInstanceId}-aiservice",
184+
"arcgis": f"mas-{masInstanceId}-arcgis",
185+
"facilities": f"mas-{masInstanceId}-facilities",
186+
"iot": f"mas-{masInstanceId}-iot",
187+
"manage": f"mas-{masInstanceId}-manage",
188+
"monitor": f"mas-{masInstanceId}-monitor",
189+
"optimizer": f"mas-{masInstanceId}-optimizer",
190+
"predict": f"mas-{masInstanceId}-predict",
191+
"visualinspection": f"mas-{masInstanceId}-visualinspection"
192+
}
193+
194+
for app in selectedApps:
195+
if app in appNamespaces:
196+
namespaces.add(appNamespaces[app])
197+
198+
return namespaces
199+
200+
201+
def _check_self_subject_access(
202+
dynClient: DynamicClient,
203+
verb: str,
204+
resource: str,
205+
group: str = "rbac.authorization.k8s.io",
206+
namespace: str | None = None
207+
) -> bool:
208+
authAPI = k8s_client.AuthorizationV1Api(dynClient.client)
209+
review = k8s_client.V1SelfSubjectAccessReview(
210+
spec=k8s_client.V1SelfSubjectAccessReviewSpec(
211+
resource_attributes=k8s_client.V1ResourceAttributes(
212+
namespace=namespace,
213+
verb=verb,
214+
resource=resource,
215+
group=group
216+
)
217+
)
218+
)
219+
result = authAPI.create_self_subject_access_review(body=review)
220+
status = getattr(result, "status", None)
221+
return bool(getattr(status, "allowed", False))
222+
223+
224+
def buildClusterAdminPermissionMatrix() -> list[dict[str, str]]:
225+
return [
226+
{"verb": "create", "resource": "namespaces", "group": ""},
227+
{"verb": "create", "resource": "clusterroles"},
228+
{"verb": "update", "resource": "clusterroles"},
229+
{"verb": "create", "resource": "clusterrolebindings"},
230+
{"verb": "update", "resource": "clusterrolebindings"},
231+
]
232+
233+
234+
def permissionCheckForRBAC(
235+
dynClient: DynamicClient,
236+
checks: list[dict[str, str]] | None = None
237+
) -> list[dict[str, str | bool]]:
238+
if checks is None:
239+
checks = buildClusterAdminPermissionMatrix()
240+
241+
results = []
242+
243+
for check in checks:
244+
verb = check["verb"]
245+
resource = check["resource"]
246+
group = check.get("group", "rbac.authorization.k8s.io")
247+
namespace = check.get("namespace")
248+
249+
allowed = _check_self_subject_access(
250+
dynClient=dynClient,
251+
verb=verb,
252+
resource=resource,
253+
group=group,
254+
namespace=namespace
255+
)
256+
257+
result: dict[str, str | bool] = {
258+
"verb": verb,
259+
"resource": resource,
260+
"group": group,
261+
"allowed": allowed
262+
}
263+
264+
if namespace is not None:
265+
result["namespace"] = namespace
266+
267+
results.append(result)
268+
269+
return results
270+
271+
272+
def applyPreInstallMASRBAC(
273+
dynClient: DynamicClient,
274+
masVersion: str,
275+
masInstanceId: str,
276+
permissionMode: str,
277+
selectedApps: list[str] | None = None,
278+
rbacRootDir: str | None = None
279+
) -> None:
280+
if not rbacRootDir:
281+
rbacRootDir = DEFAULT_PREINSTALL_MAS_RBAC_ROOT
282+
283+
validatedApps = _validate_selected_apps(selectedApps)
284+
285+
if not validatedApps:
286+
logger.info("No selected apps provided for pre-install MAS RBAC apply")
287+
return
288+
289+
manifestFiles = _discover_preinstall_mas_rbac_files(
290+
rbacRootDir=rbacRootDir,
291+
masVersion=masVersion,
292+
permissionMode=permissionMode,
293+
selectedApps=validatedApps
294+
)
295+
296+
logger.info(
297+
f"Applying pre-install MAS RBAC from {rbacRootDir} for MAS {masVersion}, "
298+
f"masInstanceId={masInstanceId}, permissionMode={permissionMode}, "
299+
f"selectedApps={sorted(validatedApps)}, "
300+
f"manifestCount={len(manifestFiles)}"
301+
)
302+
303+
if not manifestFiles:
304+
logger.info("No pre-install MAS RBAC manifests selected for apply")
305+
return
306+
307+
namespaceAPI = dynClient.resources.get(api_version="v1", kind="Namespace")
308+
requiredNamespaces = _get_preinstall_mas_rbac_namespaces(
309+
masInstanceId=masInstanceId,
310+
permissionMode=permissionMode,
311+
selectedApps=validatedApps
312+
)
313+
314+
for namespace in sorted(requiredNamespaces):
315+
logger.info(f"Ensuring namespace exists for pre-install MAS RBAC: {namespace}")
316+
namespaceAPI.apply(body={
317+
"apiVersion": "v1",
318+
"kind": "Namespace",
319+
"metadata": {
320+
"name": namespace
321+
}
322+
})
323+
324+
env = Environment()
325+
appliedResourceCount = 0
326+
327+
for manifestFile in manifestFiles:
328+
logger.info(f"Applying pre-install MAS RBAC manifest {manifestFile}")
329+
with open(manifestFile, "r") as file:
330+
template = env.from_string(file.read())
331+
renderedManifest = template.render(mas_instance_id=masInstanceId)
332+
333+
for resourceBody in yaml.safe_load_all(renderedManifest):
334+
if resourceBody is None:
335+
continue
336+
337+
apiVersion = resourceBody["apiVersion"]
338+
kind = resourceBody["kind"]
339+
metadata = resourceBody["metadata"]
340+
resourceName = metadata["name"]
341+
resourceNamespace = metadata.get("namespace")
342+
343+
if kind in {"Role", "RoleBinding"} and not resourceNamespace:
344+
raise ValueError(
345+
f"Namespaced RBAC resource {kind}/{resourceName} from {manifestFile} is missing metadata.namespace"
346+
)
347+
348+
logger.debug(
349+
f"Applying {kind} {resourceName} "
350+
f"(apiVersion={apiVersion}, namespace={resourceNamespace})"
351+
)
352+
353+
resourceAPI = dynClient.resources.get(api_version=apiVersion, kind=kind)
354+
if resourceNamespace:
355+
resourceAPI.apply(body=resourceBody, namespace=resourceNamespace)
356+
else:
357+
resourceAPI.apply(body=resourceBody)
358+
359+
appliedResourceCount += 1
360+
361+
logger.info(
362+
f"Pre-install MAS RBAC apply completed: processedFiles={len(manifestFiles)}, "
363+
f"appliedResources={appliedResourceCount}"
364+
)

src/mas/devops/templates/pipelinerun-install.yml.j2

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,10 @@ spec:
557557
- name: mas_feature_usage
558558
value: "{{ mas_feature_usage }}"
559559
{%- endif %}
560+
{%- if mas_issuer_kind is defined and mas_issuer_kind != "" %}
561+
- name: mas_issuer_kind
562+
value: "{{ mas_issuer_kind }}"
563+
{%- endif %}
560564

561565
# MAS Workspace
562566
# -------------------------------------------------------------------------

0 commit comments

Comments
 (0)