Skip to content

Commit c2dcae5

Browse files
committed
Merge branch 'release-v5.10.0'
2 parents 9b14462 + 9f4dc40 commit c2dcae5

File tree

9 files changed

+814
-1169
lines changed

9 files changed

+814
-1169
lines changed

.github/workflows/main.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ jobs:
8484
general.verbose=yes || true # FIXME: sometimes config command exits with an error
8585
nfctl demo \
8686
--size medium \
87-
--regions us-west-2 us-east-2 \
88-
--provider AWS
87+
--regions us-ashburn-1 us-phoenix-1 \
88+
--provider OCI
8989
nfctl \
9090
list services
9191
nfctl \

netfoundry/ctl.py

+106-44
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
import platform
1212
import re
1313
import signal
14+
import jwt
1415
import tempfile
16+
from builtins import list as blist
1517
from json import dumps as json_dumps
1618
from json import load as json_load
1719
from json import loads as json_loads
@@ -39,7 +41,7 @@
3941
from .network import Network, Networks
4042
from .network_group import NetworkGroup
4143
from .organization import Organization
42-
from .utility import DC_PROVIDERS, EMBED_NET_RESOURCES, MUTABLE_NET_RESOURCES, MUTABLE_RESOURCE_ABBREV, RESOURCE_ABBREV, RESOURCES, is_jwt, normalize_caseless, plural, singular
44+
from .utility import DC_PROVIDERS, EMBED_NET_RESOURCES, IDENTITY_ID_PROPERTIES, MUTABLE_NET_RESOURCES, MUTABLE_RESOURCE_ABBREV, RESOURCE_ABBREV, RESOURCES, any_in, get_generic_resource_by_type_and_id, normalize_caseless, plural, propid2type, singular
4345

4446
set_metadata(version=f"v{netfoundry_version}", author="NetFoundry", name="nfctl") # must precend import milc.cli
4547
from milc import cli, questions # this uses metadata set above
@@ -485,6 +487,14 @@ def get(cli, echo: bool = True, spinner: object = None):
485487
matches = organization.find_roles(**cli.args.query)
486488
if len(matches) == 1:
487489
match = matches[0]
490+
elif cli.args.resource_type == "region":
491+
if 'id' in query_keys:
492+
cli.log.error("regions do not have an ID property, try provider and location_code params")
493+
sysexit(1)
494+
else:
495+
matches = networks.find_regions(**cli.args.query)
496+
if len(matches) == 1:
497+
match = matches[0]
488498
elif cli.args.resource_type == "network":
489499
if 'id' in query_keys:
490500
if len(query_keys) > 1:
@@ -522,27 +532,15 @@ def get(cli, echo: bool = True, spinner: object = None):
522532
else:
523533
cli.log.error("need --network=ACMENet")
524534
sysexit(1)
525-
if cli.args.resource_type == "data-center":
526-
if 'id' in query_keys:
527-
cli.log.warn("data centers fetched by ID may not support this network's product version, try provider or locationCode params for safety")
528-
if len(query_keys) > 1:
529-
query_keys.remove('id')
530-
cli.log.warn(f"using 'id' only, ignoring params: '{', '.join(query_keys)}'")
531-
match = network.get_data_center_by_id(id=cli.args.query['id'])
532-
else:
533-
matches = network.find_edge_router_data_centers(**cli.args.query)
534-
if len(matches) == 1:
535-
match = network.get_data_center_by_id(id=matches[0]['id'])
535+
if 'id' in query_keys:
536+
if len(query_keys) > 1:
537+
query_keys.remove('id')
538+
cli.log.warn(f"using 'id' only, ignoring params: '{', '.join(query_keys)}'")
539+
match = network.get_resource_by_id(type=cli.args.resource_type, id=cli.args.query['id'], accept=cli.args.accept)
536540
else:
537-
if 'id' in query_keys:
538-
if len(query_keys) > 1:
539-
query_keys.remove('id')
540-
cli.log.warn(f"using 'id' only, ignoring params: '{', '.join(query_keys)}'")
541-
match = network.get_resource_by_id(type=cli.args.resource_type, id=cli.args.query['id'], accept=cli.args.accept)
542-
else:
543-
matches = network.find_resources(type=cli.args.resource_type, accept=cli.args.accept, params=cli.args.query)
544-
if len(matches) == 1:
545-
match = matches[0]
541+
matches = network.find_resources(type=cli.args.resource_type, accept=cli.args.accept, params=cli.args.query)
542+
if len(matches) == 1:
543+
match = matches[0]
546544

547545
if match:
548546
cli.log.debug(f"found exactly one {cli.args.resource_type} by '{', '.join(query_keys)}'")
@@ -601,18 +599,22 @@ def get(cli, echo: bool = True, spinner: object = None):
601599
@cli.argument('-k', '--keys', arg_only=True, action=StoreListKeys, help="list of keys as a,b,c to print only selected keys (columns)")
602600
@cli.argument('-m', '--my-roles', arg_only=True, action='store_true', help="filter roles by caller identity")
603601
@cli.argument('-a', '--as', dest='accept', arg_only=True, choices=['create'], help="request the as=create alternative form of the resources")
602+
@cli.argument('-n', '--names', default=False, action='store_boolean', help=argparse.SUPPRESS)
604603
@cli.argument('resource_type', arg_only=True, help='type of resource', metavar="RESOURCE_TYPE",
605604
choices=[choice for group in [[type, RESOURCES[type].abbreviation] for type in RESOURCES.keys()] for choice in group])
606605
@cli.subcommand(description='find a collection of resources by type and query')
607-
def list(cli, spinner: object = None):
608-
"""Find resources as lists."""
606+
def list(cli, echo: bool = True, spinner: object = None):
607+
"""Find resources as lists.
608+
609+
:param echo: False allows the caller to capture the return instead of printing the match
610+
"""
609611
if not spinner:
610612
spinner = get_spinner(cli, "working")
611613
else:
612614
cli.log.debug("got spinner as function param")
613615
if RESOURCE_ABBREV.get(cli.args.resource_type):
614616
cli.args.resource_type = RESOURCE_ABBREV[cli.args.resource_type].name
615-
if cli.args.accept and not MUTABLE_NET_RESOURCES.get(cli.args.resource_type): # mutable excludes data-centers
617+
if cli.args.accept and not MUTABLE_NET_RESOURCES.get(cli.args.resource_type):
616618
cli.log.warn("the --as=ACCEPT param is not applicable to resources outside the network domain")
617619
if cli.args.query and cli.args.query.get('id'):
618620
cli.log.warn("try 'get' command to get by id")
@@ -627,6 +629,8 @@ def list(cli, spinner: object = None):
627629
spinner.text = f"Finding {cli.args.resource_type} {'by' if query_keys else '...'} {', '.join(query_keys)}"
628630
else:
629631
spinner.text = f"Finding all {cli.args.resource_type}"
632+
if not echo:
633+
spinner.enabled = False
630634
with spinner:
631635
organization, networks = use_organization(cli, spinner)
632636
if cli.args.resource_type == "organizations":
@@ -645,6 +649,8 @@ def list(cli, spinner: object = None):
645649
matches = organization.find_roles(**cli.args.query)
646650
else:
647651
matches = organization.find_roles(**cli.args.query)
652+
elif cli.args.resource_type == "regions":
653+
matches = networks.find_regions(**cli.args.query)
648654
elif cli.args.resource_type == "networks":
649655
if cli.config.general.network_group:
650656
network_group = use_network_group(
@@ -666,10 +672,7 @@ def list(cli, spinner: object = None):
666672
else:
667673
cli.log.error("first configure a network: '--network=ACMENet'")
668674
sysexit(1)
669-
if cli.args.resource_type == "data-centers":
670-
matches = network.find_edge_router_data_centers(**cli.args.query)
671-
else:
672-
matches = network.find_resources(type=cli.args.resource_type, accept=cli.args.accept, params=cli.args.query)
675+
matches = network.find_resources(type=cli.args.resource_type, accept=cli.args.accept, params=cli.args.query)
673676

674677
if len(matches) == 0:
675678
spinner.fail(f"Found no {cli.args.resource_type} by '{', '.join(query_keys)}'")
@@ -678,20 +681,26 @@ def list(cli, spinner: object = None):
678681
cli.log.debug(f"found at least one {cli.args.resource_type} by '{', '.join(query_keys)}'")
679682

680683
valid_keys = set()
684+
for match in matches:
685+
# cli.log.debug(match)
686+
valid_keys = valid_keys.union(match.keys())
687+
688+
# intersection of the set of valid, observed keys in the first match
689+
default_keys = ['name', 'label', 'organizationShortName', 'type', 'description',
690+
'edgeRouterAttributes', 'serviceAttributes', 'endpointAttributes',
691+
'status', 'zitiId', 'provider', 'locationCode', 'ipAddress', 'networkVersion',
692+
'active', 'default', 'region', 'size', 'attributes', 'email', 'productVersion',
693+
'address', 'binding', 'component']
694+
if cli.config.list.names: # include identity IDs if --names
695+
default_keys.extend(IDENTITY_ID_PROPERTIES)
681696
if cli.args.keys:
682-
# intersection of the set of valid, observed keys in the first match
683-
# and the set of configured, desired keys
684-
valid_keys = set(matches[0].keys()) & set(cli.args.keys)
685-
elif cli.args.output == "text":
686-
default_columns = ['name', 'label', 'organizationShortName', 'type', 'description',
687-
'edgeRouterAttributes', 'serviceAttributes', 'endpointAttributes',
688-
'status', 'zitiId', 'provider', 'locationCode', 'ipAddress', 'networkVersion',
689-
'active', 'default', 'region', 'size', 'attributes', 'email', 'productVersion',
690-
'address', 'binding', 'component']
691-
valid_keys = set(matches[0].keys()) & set(default_columns)
697+
valid_keys = valid_keys.intersection(cli.args.keys)
698+
else:
699+
valid_keys = valid_keys.intersection(default_keys)
700+
cli.log.debug(f"filtering matches for valid keys: {str(valid_keys)}")
692701

693702
if valid_keys:
694-
cli.log.debug(f"valid keys: {str(valid_keys)}")
703+
cli.log.debug(f"filtering matches for valid keys: {str(valid_keys)}")
695704
filtered_matches = []
696705
for match in matches:
697706
filtered_match = {key: match[key] for key in match.keys() if key in valid_keys}
@@ -700,7 +709,33 @@ def list(cli, spinner: object = None):
700709
cli.log.debug("not filtering output keys")
701710
filtered_matches = matches
702711

712+
# if echo=False then return the object and parent objects instead of printing with an output format
713+
if not echo:
714+
return filtered_matches, organization
715+
703716
if cli.args.output == "text":
717+
if cli.config.list.names:
718+
# map any property names that look like a resource ID to the appropriate resource type so we can look up the name later
719+
type_by_prop = dict()
720+
for key in valid_keys:
721+
if key not in ['zitiId']:
722+
if key in IDENTITY_ID_PROPERTIES:
723+
type_by_prop[key] = 'identities'
724+
elif key.endswith('Id'):
725+
type_by_prop[key] = propid2type(key)
726+
727+
for match in filtered_matches: # for each match
728+
if any_in(type_by_prop.keys(), match.keys()): # if at least one property points to a resolvable ID (fast)
729+
for k, v in match.items(): # for each key in match (slow)
730+
if type_by_prop.get(k): # if this is the property that points to a resolvable ID
731+
if v is None:
732+
cli.log.debug(f"unexpected value for {k} = {v}")
733+
continue
734+
# get the resource with the name we're after
735+
resource, status = get_generic_resource_by_type_and_id(setup=organization, resource_type=type_by_prop[k], resource_id=v)
736+
if resource.get('name'): # if the name property isn't empty
737+
match[k] = f"{resource['name']}" # wedge the name into the ID column
738+
704739
if cli.config.general.headers:
705740
table_headers = filtered_matches[0].keys()
706741
else:
@@ -898,7 +933,7 @@ def demo(cli):
898933
name=network_name,
899934
size=cli.config.demo.size,
900935
version=cli.config.demo.product_version,
901-
wait=0) # FIXME: don't use wait > 0 until process-executions beta is launched, until then poll for status
936+
)
902937
network, network_group = use_network(
903938
cli,
904939
organization=organization,
@@ -913,8 +948,8 @@ def demo(cli):
913948
# a list of locations to place a hosted router
914949
fabric_placements = []
915950
for region in cli.config.demo.regions:
916-
dc_matches = network.find_edge_router_data_centers(provider=cli.config.demo.provider, location_code=region)
917-
if not len(dc_matches) == 1:
951+
region_matches = networks.find_regions(providers=[cli.config.demo.provider], location_code=region)
952+
if not len(region_matches) == 1:
918953
raise RuntimeError(f"invalid region '{region}'")
919954
else:
920955
existing_count = len([er for er in hosted_edge_routers if er['provider'] == cli.config.demo.provider and er['region'] == region])
@@ -1153,7 +1188,7 @@ def use_organization(cli, spinner: object = None, prompt: bool = True):
11531188
raise NFAPINoCredentials()
11541189
spinner.succeed(f"Logged in profile '{cli.config.general.profile}'")
11551190
cli.log.debug(f"logged-in organization label is {organization.label}.")
1156-
networks = Networks(Organization=organization)
1191+
networks = Networks(setup=organization)
11571192
return organization, networks
11581193

11591194

@@ -1181,7 +1216,7 @@ def use_network_group(cli, organization: object, group: str = None, spinner: obj
11811216
return network_group
11821217

11831218

1184-
def use_network(cli, organization: object, network_name: str = None, group: str = None, spinner: object = None):
1219+
def use_network(cli, organization: Organization, network_name: str = None, group: str = None, spinner: object = None):
11851220
"""
11861221
Use a network.
11871222
@@ -1334,6 +1369,33 @@ def get_spinner(cli, text):
13341369
return inner_spinner
13351370

13361371

1372+
def jwt_decode(token):
1373+
# TODO: figure out how to stop doing this because the token is for the
1374+
# API, not this app, and so may change algorithm unexpectedly or stop
1375+
# being a JWT altogether, currently needed to build the URL for HTTP
1376+
# requests, might need to start using env config
1377+
"""Parse the token and return claimset."""
1378+
try:
1379+
claim = jwt.decode(jwt=token, algorithms=["RS256"], options={"verify_signature": False})
1380+
except jwt.exceptions.PyJWTError as e:
1381+
raise jwt.exceptions.PyJWTError(f"failed to parse bearer token as JWT, caught {e}")
1382+
except Exception as e:
1383+
raise RuntimeError(f"unexpected error parsing JWT, caught {e}")
1384+
return claim
1385+
1386+
1387+
def is_jwt(token):
1388+
"""If is a JWT then True."""
1389+
try:
1390+
jwt_decode(token)
1391+
except jwt.exceptions.PyJWTError:
1392+
return False
1393+
except Exception as e:
1394+
raise RuntimeError(f"unexpected error parsing JWT, caught {e}")
1395+
else:
1396+
return True
1397+
1398+
13371399
yaml_lexer = get_lexer_by_name("yaml", stripall=True)
13381400
json_lexer = get_lexer_by_name("json", stripall=True)
13391401
bash_lexer = get_lexer_by_name("bash", stripall=True)

0 commit comments

Comments
 (0)