Skip to content

Commit 98d7bff

Browse files
committed
examples/resouces: studio_update support multi-studio get/set
Change-Id: Ia6bf15862a11c0cdb16b0aed6a7758e323f7273c
1 parent 819a2a2 commit 98d7bff

1 file changed

Lines changed: 75 additions & 34 deletions

File tree

examples/resources/studio/studio_update.py

Lines changed: 75 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,25 @@ class InputPathNotFoundError(Exception):
6969
'''Indicates that a requested studio input path does not exist.'''
7070

7171

72+
def load_yaml_file(filename):
73+
'''Loads YAML and converts parser failures into concise input errors.'''
74+
try:
75+
with open(filename, encoding='utf8') as f:
76+
return yaml.safe_load(f)
77+
except yaml.YAMLError as err:
78+
problem = getattr(err, 'problem', None) or 'invalid YAML syntax'
79+
context = getattr(err, 'context', None)
80+
if context:
81+
problem = f'{context}; {problem}'
82+
mark = (getattr(err, 'context_mark', None)
83+
or getattr(err, 'problem_mark', None))
84+
location = ''
85+
if mark is not None:
86+
location = f' near line {mark.line + 1}, column {mark.column + 1}'
87+
raise ValueError(
88+
f"Invalid YAML in {filename}{location}: {problem}") from None
89+
90+
7291
# CHANGE_SIGNATURE
7392
# - substring used in workspace and change control names,
7493
# used to identify changes automated by this script
@@ -204,46 +223,52 @@ def mergeInputs(root=None, path=None, inputs=None):
204223
return root
205224

206225

207-
def get_input_paths(filename):
208-
'''Loads and validates one or more input paths from a YAML file.'''
209-
with open(filename, encoding='utf8') as f:
210-
config = yaml.safe_load(f)
226+
def get_input_paths(filename, args_studio_id):
227+
'''Loads and validates one or more Studio IDs and input paths from YAML.'''
228+
config = load_yaml_file(filename)
211229

212230
configs = config if isinstance(config, list) else [config]
213231
if not configs:
214232
raise ValueError(f"Invalid YAML format in {filename}. "
215233
"Expected at least one path.")
216234

217-
paths = []
235+
studio_paths = []
218236
for i, cfg in enumerate(configs, start=1):
219-
if not isinstance(cfg, dict) or set(cfg) != {'path'}:
237+
if (not isinstance(cfg, dict) or 'path' not in cfg
238+
or not set(cfg).issubset({'studioId', 'path'})):
239+
raise ValueError(f"Invalid YAML format in {filename} "
240+
f"for config at index {i}. "
241+
"Expected a dict containing 'path' and optionally "
242+
"'studioId'.")
243+
sid = cfg.get('studioId', args_studio_id)
244+
if not isinstance(sid, str) or not sid:
220245
raise ValueError(f"Invalid YAML format in {filename} "
221246
f"for config at index {i}. "
222-
"Expected a dict containing only a 'path' key.")
247+
"The 'studioId' value must be a non-empty string.")
223248
path = cfg['path']
224249
if (not isinstance(path, list)
225250
or not all(isinstance(elem, str) for elem in path)):
226251
raise ValueError(f"Invalid YAML format in {filename} "
227252
f"for config at index {i}. "
228253
"The 'path' value must be a list of strings.")
229-
paths.append(path)
230-
return paths, isinstance(config, list)
254+
studio_paths.append((sid, path))
255+
return studio_paths, isinstance(config, list)
231256

232257

233-
async def get_inputs(channel, filename, path_filename=None):
258+
async def get_inputs(channel, args_studio_id, filename, path_filename=None):
234259
'''
235260
Gets studio inputs from the mainline, defaulting to the root path,
236261
but optionally at paths in a YAML file.
237262
Dumps them into a file named <studio_id>_inputs.yaml.
238263
'''
239-
sid = studio_id
264+
sid = args_studio_id
240265
if path_filename is not None:
241-
paths, multiple = get_input_paths(path_filename)
266+
studio_paths, multiple = get_input_paths(path_filename, sid)
242267
stub = studio.InputsServiceStub(channel)
243268
path_inputs = []
244-
for path in paths:
269+
for path_sid, path in studio_paths:
245270
key = studio.InputsKey(
246-
studio_id=sid,
271+
studio_id=path_sid,
247272
workspace_id=MAINLINE_WS_ID,
248273
path=fmp.RepeatedString(values=path),
249274
)
@@ -252,9 +277,11 @@ async def get_inputs(channel, filename, path_filename=None):
252277
resp = await stub.get_one(req, timeout=RPC_TIMEOUT)
253278
except GRPCError as err:
254279
if err.status == Status.NOT_FOUND:
255-
raise InputPathNotFoundError(path) from None
280+
raise InputPathNotFoundError(
281+
f"studio {path_sid!r}, path {path}") from None
256282
raise
257283
path_inputs.append({
284+
'studioId': path_sid,
258285
'path': path,
259286
'inputs': json.loads(resp.value.inputs),
260287
})
@@ -274,7 +301,11 @@ async def get_inputs(channel, filename, path_filename=None):
274301
path = resp.value.key.path.values
275302
split = json.loads(resp.value.inputs)
276303
mergedinputs = mergeInputs(mergedinputs, path, split)
277-
jsonPathInputs = {'path': [], 'inputs': mergedinputs}
304+
jsonPathInputs = {
305+
'studioId': sid,
306+
'path': [],
307+
'inputs': mergedinputs,
308+
}
278309
with open(filename, 'w', encoding='utf8') as f:
279310
yaml.dump(jsonPathInputs, f)
280311

@@ -398,14 +429,15 @@ def getActionTriggers(filename):
398429
return input_path, dyn_names, dyn_values
399430

400431

401-
async def update_inputs_via_autofill(channel, ws_id, path, dyn_names, dyn_value):
432+
async def update_inputs_via_autofill(
433+
channel, ws_id, args_studio_id, action_id, path, dyn_names, dyn_value):
402434
'''
403435
Sets inputs to the studio using autofill action.
404436
'''
405437
exec_id = str(uuid.uuid4())
406438
dynamicArgs = {
407439
"InputPath": path,
408-
"StudioID": studio_id,
440+
"StudioID": args_studio_id,
409441
"WorkspaceID": ws_id,
410442
}
411443
for dyn_name in dyn_names:
@@ -441,13 +473,15 @@ async def update_inputs_via_autofill(channel, ws_id, path, dyn_names, dyn_value)
441473
break
442474

443475

444-
async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
476+
async def update_inputs_via_yaml(
477+
channel, ws_id, args_studio_id, filename, dev_ids):
445478
'''
446479
Adds or removes studio inputs using the yaml file.
447480
Optionally assigns studio to a set of devices.
448481
449482
Supports two formats:
450483
1. Single input update:
484+
studioId: ... # Optional; defaults to --studio-id
451485
path: [...]
452486
inputs: {...}
453487
@@ -462,8 +496,7 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
462496
remove: true
463497
'''
464498
# convert YAML input file to json inputs.
465-
with open(f'{filename}', encoding='utf8') as f:
466-
config = yaml.safe_load(f)
499+
config = load_yaml_file(filename)
467500

468501
# Determine if this is a single input update or multiple updates.
469502
configs = []
@@ -486,6 +519,12 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
486519
f"for config at index {i}. "
487520
"Expected a dict with a 'path' key.")
488521

522+
config_studio_id = cfg.get('studioId', args_studio_id)
523+
if not isinstance(config_studio_id, str) or not config_studio_id:
524+
raise ValueError(f"Invalid YAML format in {filename} "
525+
f"for config at index {i}. "
526+
"The 'studioId' value must be a non-empty string.")
527+
489528
has_inputs = 'inputs' in cfg
490529
remove = cfg.get('remove', False)
491530
if not isinstance(remove, bool):
@@ -509,7 +548,7 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
509548
"The 'path' value must be a list of strings.")
510549
key = studio.InputsKey(
511550
workspace_id=ws_id,
512-
studio_id=studio_id,
551+
studio_id=config_studio_id,
513552
path=fmp.RepeatedString(values=path)
514553
)
515554
if remove:
@@ -529,8 +568,8 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
529568
value=value
530569
)
531570
await stub.set(req, timeout=RPC_TIMEOUT)
532-
logger.info('\tStudio inputs %s (%d/%d) - path: %s',
533-
operation, i, len(configs), path)
571+
logger.info('\tStudio inputs %s (%d/%d) - studio: %s, path: %s',
572+
operation, i, len(configs), config_studio_id, path)
534573

535574
logger.info('All studio input updates applied from yaml: %s', filename)
536575

@@ -542,7 +581,7 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
542581
value=studio.AssignedTagsConfig(
543582
key=studio.StudioKey(
544583
workspace_id=ws_id,
545-
studio_id=studio_id
584+
studio_id=args_studio_id
546585
),
547586
query=f'device:{",".join(dev_ids)}'
548587
)
@@ -1009,14 +1048,14 @@ async def main(args, client):
10091048
with client as channel:
10101049
# Get Inputs
10111050
if args.operation == 'get':
1012-
filename = f'{studio_id}-inputs.yaml'
1051+
filename = f'{args.studio_id}-inputs.yaml'
10131052
path_filename = args.yaml_file.name if args.yaml_file else None
1014-
await get_inputs(channel, filename, path_filename)
1053+
await get_inputs(channel, args.studio_id, filename, path_filename)
10151054
logger.info('Mainline inputs have been written to: %s', filename)
10161055
return
10171056
# Set Inputs in Multiple Steps
10181057
# Create a workspace.
1019-
workspace_name = f'{studio_id} {CHANGE_SIGNATURE}'
1058+
workspace_name = f'{args.studio_id} {CHANGE_SIGNATURE}'
10201059
if args.wsid:
10211060
ws_id = args.wsid
10221061
else:
@@ -1026,15 +1065,16 @@ async def main(args, client):
10261065
actionInvoked = False
10271066
if args.yaml_file:
10281067
await update_inputs_via_yaml(
1029-
channel, ws_id, args.yaml_file.name, "*")
1068+
channel, ws_id, args.studio_id, args.yaml_file.name, "*")
10301069
inputSet = True
10311070
# Update the studio with autofill action
10321071
dyn_values = []
10331072
if args.action_file:
10341073
input_path, dyn_names, dyn_values = getActionTriggers(args.action_file.name)
10351074
for dyn_value in dyn_values:
10361075
await update_inputs_via_autofill(
1037-
channel, ws_id, input_path, dyn_names, dyn_value)
1076+
channel, ws_id, args.studio_id, args.action_id,
1077+
input_path, dyn_names, dyn_value)
10381078
actionInvoked = True
10391079
if not inputSet and not actionInvoked:
10401080
return
@@ -1139,7 +1179,8 @@ def check_cloudvision_version():
11391179
" Example:\n"
11401180
" - path: ['tenants', '[name=Arista1]', 'vlans', '[vlanId=100]']\n"
11411181
" remove: true\n"
1142-
" - path: ['tenants', '[name=Arista1]', 'vlans', '[vlanId=101]']\n"
1182+
" - studioId: enterprise-routing-studio\n"
1183+
" path: ['tenants', '[name=Arista1]', 'vlans', '[vlanId=101]']\n"
11431184
" inputs:\n"
11441185
" name: vlan101\n"
11451186
" vlanId: 101\n"
@@ -1178,12 +1219,12 @@ def check_cloudvision_version():
11781219
parser.add_argument("--insecure", action="store_true", default=False,
11791220
help="skip TLS certificate verification")
11801221
pargs = parser.parse_args()
1181-
studio_id = pargs.studio_id
1182-
if pargs.action_id:
1183-
action_id = pargs.action_id
11841222
conn = create_client(pargs)
11851223
try:
11861224
asyncio.run(main(pargs, conn))
11871225
except InputPathNotFoundError as err:
11881226
logger.error("Studio inputs path does not exist: %s", err)
11891227
sys.exit(1)
1228+
except ValueError as err:
1229+
logger.error("%s", err)
1230+
sys.exit(1)

0 commit comments

Comments
 (0)