Skip to content

Commit 34d07a2

Browse files
authored
Merge pull request #104 from fermitools/newCapabilitySet
Added workflow for creating a new capability set that mirrors https://github.com/DUNE/data-mgmt-ops/wiki/Dune-Physicsgroups-disk--how-to-create-a-new-physics-group
2 parents cfca9d1 + 039be98 commit 34d07a2

File tree

6 files changed

+566
-7
lines changed

6 files changed

+566
-7
lines changed

ferry_cli/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ def run(
368368
workflow.init_parser()
369369
workflow_params, _ = workflow.parser.parse_known_args(endpoint_args)
370370
json_result = workflow.run(self.ferry_api, vars(workflow_params)) # type: ignore
371-
if not dryrun:
371+
if (not dryrun) and json_result:
372372
self.handle_output(
373373
json.dumps(json_result, indent=4), args.output, debug_level
374374
)

ferry_cli/helpers/api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ def call_endpoint(
4646
) -> Any:
4747
# Create a session object to persist certain parameters across requests
4848
if self.dryrun:
49-
print(f"\nWould call endpoint: {self.base_url}{endpoint}")
49+
print(
50+
f"\nWould call endpoint: {self.base_url}{endpoint} with params\n{params}"
51+
)
5052
return None
5153

5254
debug = self.debug_level == DebugLevel.DEBUG
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
# pylint: disable=invalid-name,arguments-differ,unused-import
2+
import sys
3+
from typing import Any, List
4+
5+
try:
6+
from ferry_cli.helpers.api import FerryAPI
7+
from ferry_cli.helpers.auth import DebugLevel
8+
from ferry_cli.helpers.workflows import Workflow
9+
except ImportError:
10+
from helpers.api import FerryAPI # type: ignore
11+
from helpers.auth import DebugLevel # type: ignore
12+
from helpers.workflows import Workflow # type: ignore
13+
14+
15+
class NewCapabilitySet(Workflow):
16+
def __init__(self: "NewCapabilitySet") -> None:
17+
self.name = "newCapabilitySet"
18+
self.method = "PUT"
19+
self.description = (
20+
"Creates a new capability set based on a given role and unix group"
21+
)
22+
self.params = [
23+
{
24+
"name": "groupname",
25+
"description": "UNIX group the new capability set will be associated with",
26+
"type": "string",
27+
"required": True,
28+
},
29+
{
30+
"name": "gid",
31+
"description": "GID of the UNIX group groupname",
32+
"type": int,
33+
"required": True,
34+
},
35+
{
36+
"name": "unitname",
37+
"description": "Affiliation Unit the new capability set will be associated with",
38+
"type": "string",
39+
"required": True,
40+
},
41+
{
42+
"name": "fqan",
43+
"description": "FQAN associated with the new capability set",
44+
"type": "string",
45+
"required": True,
46+
},
47+
{
48+
"name": "setname",
49+
"description": "Name of the new capability set",
50+
"type": "string",
51+
"required": True,
52+
},
53+
{
54+
"name": "scopes_pattern",
55+
"description": "Scopes of the new capability set",
56+
"type": "string",
57+
"required": True,
58+
},
59+
{
60+
"name": "mapped_user",
61+
"description": "If the capability set needs to be mapped to a specific user, this is that mapped username",
62+
"type": "string",
63+
"required": False,
64+
},
65+
{
66+
"name": "token_subject",
67+
"description": (
68+
"The default will just be [email protected], but if the resultant token should have the user UID from FERRY as the subject "
69+
+ 'then set this to the string "none"'
70+
),
71+
"type": "string",
72+
"required": False,
73+
},
74+
]
75+
super().__init__()
76+
77+
def run(self: "NewCapabilitySet", api: "FerryAPI", args: Any) -> Any: # type: ignore # pylint: disable=arguments-differ,too-many-branches,too-many-statements
78+
"""Run the workflow to add a new capability set to FERRY"""
79+
if api.dryrun:
80+
print(
81+
"WARNING: This workflow is being run with the --dryrun flag. The exact steps shown here may differ since "
82+
"some of the workflow steps depend on the output of API calls."
83+
)
84+
85+
# Note - we don't have explicit dryrun checks here because the FerryAPI class handles that for us
86+
# 1. Create new group in FERRY
87+
try:
88+
self.verify_output(
89+
api,
90+
api.call_endpoint(
91+
"createGroup",
92+
method="PUT",
93+
params={
94+
"groupname": args["groupname"],
95+
"gid": args["gid"],
96+
"grouptype": "UnixGroup",
97+
},
98+
),
99+
)
100+
except Exception as e: # pylint: disable=broad-except
101+
if api.debug_level != DebugLevel.QUIET:
102+
print("Failed to create group")
103+
if "groupname already exists" in str(e):
104+
print(
105+
f"Group {args['groupname']} already exists in FERRY. Continuing with the workflow."
106+
)
107+
else:
108+
raise
109+
110+
# Check
111+
if not api.dryrun:
112+
try:
113+
response = self.verify_output(
114+
api,
115+
api.call_endpoint(
116+
"getGroupName",
117+
params={"gid": args["gid"]},
118+
),
119+
)
120+
if response["groupname"] != args["groupname"]:
121+
print(
122+
f"Group name {response['groupname']} does not match expected group name {args['groupname']}"
123+
)
124+
raise ValueError("Group name mismatch")
125+
except Exception: # pylint: disable=broad-except
126+
if api.debug_level != DebugLevel.QUIET:
127+
print("Failed to verify group creation")
128+
raise
129+
130+
# 2. Add group to unit
131+
try:
132+
self.verify_output(
133+
api,
134+
api.call_endpoint(
135+
"addGroupToUnit",
136+
method="PUT",
137+
params={
138+
"groupname": args["groupname"],
139+
"unitname": args["unitname"],
140+
"grouptype": "UnixGroup",
141+
},
142+
),
143+
)
144+
except Exception: # pylint: disable=broad-except
145+
if api.debug_level != DebugLevel.QUIET:
146+
print("Failed to add group to unit")
147+
raise
148+
149+
# Check
150+
if not api.dryrun:
151+
try:
152+
response = self.verify_output(
153+
api,
154+
api.call_endpoint(
155+
"getGroupUnits",
156+
params={"groupname": args["groupname"]},
157+
),
158+
)
159+
units = (entry["unitname"] for entry in response)
160+
for unit in units:
161+
if unit == args["unitname"]:
162+
break
163+
else:
164+
raise ValueError(
165+
f"Group {args['groupname']} does not belong to unit {args['unitname']}"
166+
)
167+
except Exception: # pylint: disable=broad-except
168+
if api.debug_level != DebugLevel.QUIET:
169+
print("Failed to verify group-unit association")
170+
raise
171+
172+
# TODO Test this case # pylint: disable=fixme
173+
# 2a. Optional - add mapped user to group
174+
if args.get("mapped_user", ""):
175+
try:
176+
self.verify_output(
177+
api,
178+
api.call_endpoint(
179+
"addUserToGroup",
180+
method="PUT",
181+
params={
182+
"groupname": args["groupname"],
183+
"username": args["mapped_user"],
184+
"grouptype": "UnixGroup",
185+
},
186+
),
187+
)
188+
except Exception:
189+
if api.debug_level != DebugLevel.QUIET:
190+
print("Failed to add mapped user to group")
191+
raise
192+
# Check
193+
if not api.dryrun:
194+
try:
195+
response = self.verify_output(
196+
api,
197+
api.call_endpoint(
198+
"getGroupMembers",
199+
params={"groupname": args["groupname"]},
200+
),
201+
)
202+
users = (entry["username"] for entry in response)
203+
for user in users:
204+
if user == args["mapped_user"]:
205+
break
206+
else:
207+
raise ValueError(
208+
f"Mapped user {args['mapped_user']} does not belong to group {args['groupname']}"
209+
)
210+
except Exception:
211+
if api.debug_level != DebugLevel.QUIET:
212+
print("Failed to verify mapped user-group association")
213+
raise
214+
215+
# 3. Create new FQAN
216+
try:
217+
params = {
218+
"fqan": args["fqan"],
219+
"unitname": args["unitname"],
220+
"groupname": args["groupname"],
221+
}
222+
if args.get("mapped_user"):
223+
params["username"] = args["mapped_user"]
224+
225+
self.verify_output(
226+
api,
227+
api.call_endpoint(
228+
"createFQAN",
229+
method="PUT",
230+
params=params,
231+
),
232+
)
233+
except Exception: # pylint: disable=broad-except
234+
if api.debug_level != DebugLevel.QUIET:
235+
print("Failed to create FQAN")
236+
raise
237+
238+
# No Check available for FQAN creation at this time
239+
240+
# 4. Create capability set
241+
try:
242+
new_cap_set_params = {
243+
"setname": args["setname"],
244+
"pattern": args["scopes_pattern"],
245+
}
246+
if args.get("token_subject", None) is not None:
247+
new_cap_set_params["token_subject"] = args["token_subject"]
248+
249+
self.verify_output(
250+
api,
251+
api.call_endpoint(
252+
"createCapabilitySet",
253+
method="PUT",
254+
params=new_cap_set_params,
255+
),
256+
)
257+
except Exception: # pylint: disable=broad-except
258+
if api.debug_level != DebugLevel.QUIET:
259+
print("Failed to create capability set")
260+
raise
261+
# Check will be after next step
262+
263+
# 5. Associate capability set with FQAN
264+
role = self._calculate_role(args["fqan"])
265+
if not role:
266+
print(f"Failed to calculate role from FQAN {args['fqan']}")
267+
raise ValueError("Role calculation failed")
268+
try:
269+
self.verify_output(
270+
api,
271+
api.call_endpoint(
272+
"addCapabilitySetToFQAN",
273+
method="PUT",
274+
params={
275+
"setname": args["setname"],
276+
"unitname": args["unitname"],
277+
"role": role,
278+
},
279+
),
280+
)
281+
except Exception: # pylint: disable=broad-except
282+
if api.debug_level != DebugLevel.QUIET:
283+
print("Failed to associate capability set with FQAN")
284+
raise
285+
286+
# Check all capability set settings
287+
if not api.dryrun:
288+
try:
289+
response = self.verify_output(
290+
api,
291+
api.call_endpoint(
292+
"getCapabilitySet",
293+
params={"setname": args["setname"]},
294+
),
295+
)
296+
# For some reason, the getCapabilitySet API returns a list, so we need to extract the first element
297+
set_info = response[0]
298+
299+
# Verify that the capability set name matches the expected name
300+
try:
301+
assert set_info["setname"] == args["setname"]
302+
except AssertionError:
303+
raise ValueError(
304+
f"Capability set name {set_info['setname']} does not match expected name {args['setname']}"
305+
)
306+
307+
# Verify that the capability set pattern matches the expected pattern
308+
try:
309+
assert self._check_lists_for_same_elts(
310+
set_info["patterns"],
311+
self.scopes_string_to_list(args["scopes_pattern"]),
312+
)
313+
except AssertionError:
314+
raise ValueError(
315+
f"Capability set pattern {set_info['patterns']} does not match expected pattern {args['scopes_pattern']}"
316+
)
317+
318+
# Verify that the capability set FQAN and role matches the expected FQAN and role
319+
role_entries = (entry for entry in set_info["roles"])
320+
for entry in role_entries:
321+
if entry["role"] == role:
322+
try:
323+
assert entry["fqan"] == args["fqan"]
324+
except AssertionError:
325+
raise ValueError(
326+
f"Capability set role {entry['role']} does not match expected role {role}"
327+
)
328+
break # Good case - role and fqan match
329+
else:
330+
raise ValueError(
331+
f"Capability set role does not match expected role {role} or FQAN {args['fqan']} is not found in proper role entry"
332+
)
333+
except Exception: # pylint: disable=broad-except
334+
if api.debug_level != DebugLevel.QUIET:
335+
print("Failed to verify capability set creation")
336+
raise
337+
print(f"Successfully created capability set {args['setname']}.")
338+
339+
@staticmethod
340+
def scopes_string_to_list(
341+
scopes_string: str, out_delimiter: str = ","
342+
) -> List[str]:
343+
"""Convert a scopes string to a list of scopes delimited by out_delimiter
344+
e.g. "scope1,scope2" -> ["scope1", "scope2"]
345+
"""
346+
if not scopes_string:
347+
return []
348+
return scopes_string.split(out_delimiter)
349+
350+
@staticmethod
351+
def _check_lists_for_same_elts(list1: List[str], list2: List[str]) -> bool:
352+
"""Compare two lists for the same elements, regardless of order"""
353+
return sorted(list1) == sorted(list2)
354+
355+
@staticmethod
356+
def _calculate_role(fqan: str) -> str:
357+
"""Calculate the role from the FQAN
358+
Something like /fermilab/Role=Rolename/Capability=NULL -> Rolename
359+
"""
360+
parts = fqan.split("/")
361+
for part in parts:
362+
if part.startswith("Role="):
363+
return part.split("=")[1]
364+
return ""

0 commit comments

Comments
 (0)