Skip to content

Commit 48bfa3a

Browse files
committed
protect stdout for YAML and JSON
1 parent df54ade commit 48bfa3a

File tree

1 file changed

+89
-41
lines changed

1 file changed

+89
-41
lines changed

netfoundry/ctl.py

Lines changed: 89 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@
1313
import sys
1414
import tempfile
1515
import time
16+
import logging
1617
#from base64 import b64decode
1718
from json import dumps as json_dumps
1819
from json import loads as json_loads
20+
from re import sub
1921
from subprocess import call
2022

2123
#from cryptography.hazmat.primitives.serialization import Encoding, pkcs7
2224
from jwt.exceptions import PyJWTError
2325
from milc import set_metadata
26+
from packaging import version
2427
#from requests import get as http_get
2528
from tabulate import tabulate
2629
from yaml import dump as yaml_dumps
@@ -32,8 +35,8 @@
3235
from .network import Network
3336
from .network_group import NetworkGroup
3437
from .organization import Organization
35-
from .utility import MUTABLE_NETWORK_RESOURCES, NETWORK_RESOURCES, RESOURCES, Utility, plural, singular
36-
from packaging import version
38+
from .utility import (MUTABLE_NETWORK_RESOURCES, NETWORK_RESOURCES, RESOURCES,
39+
Utility, plural, singular)
3740

3841
set_metadata(version="v"+get_versions()['version']) # must precend import milc.cli
3942
import milc.subcommand.config
@@ -300,24 +303,22 @@ def create(cli):
300303
cli.log.error("failed to parse input lines as an object (deserialized JSON or YAML)")
301304
exit(1)
302305

303-
# if stdout is connected to a terminal then open the create/template input for editing and finish create on exit
304-
if sys.stdout.isatty():
305-
create_object = edit_object_as_yaml(create_input_object)
306-
else:
307-
create_object = create_input_object
306+
create_object = edit_object_as_yaml(create_input_object)
308307

309308
organization = use_organization()
310309

311310
if cli.args.resource_type == "network":
312311
if cli.config.general.network_group:
313312
network_group = use_network_group(organization=organization)
314-
elif len(organization.get_network_groups_by_organization()) > 1:
315-
cli.log.error("specify --network-group because there is more than one available to caller's identity")
316-
raise SystemExit()
317313
else:
318-
network_group_id = organization.get_network_groups_by_organization()[0]['id']
319-
network_group = use_network_group(organization=organization, id=network_group_id)
320-
resource = network.create_resource(type=cli.args.resource_type, properties=create_object, wait=cli.config.create.wait)
314+
org_count = len(organization.get_network_groups_by_organization())
315+
if org_count > 1:
316+
cli.log.error("specify --network-group because there is more than one available to caller's identity")
317+
exit(org_count)
318+
else: # use the only available group
319+
network_group_id = organization.get_network_groups_by_organization()[0]['id']
320+
network_group = use_network_group(organization=organization, group=network_group_id)
321+
network = network_group.create_network(**create_object)
321322
else:
322323
network, network_group = use_network(
323324
organization=organization,
@@ -349,7 +350,8 @@ def edit(cli):
349350
@cli.argument('resource_type', arg_only=True, help='type of resource', choices=[singular(type) for type in RESOURCES.keys()])
350351
@cli.subcommand('get a single resource by query')
351352
def get(cli, echo: bool=True):
352-
"""Get a single resource as a dictionary."""
353+
"""Get a single resource as YAML or JSON"""
354+
cli.log.setLevel(logging.WARN) # don't emit INFO messages to stdout because they will break deserialization
353355
organization = use_organization()
354356
match = {}
355357
matches = []
@@ -457,8 +459,12 @@ def get(cli, echo: bool=True):
457459
@cli.subcommand('find resources as lists')
458460
def list(cli):
459461
"""Find resources as lists."""
460-
if not sys.stdout.isatty():
461-
cli.log.warn("nfctl does not have a stable CLI interface. Use with caution in scripts.")
462+
if cli.config.general.output == "text":
463+
if not sys.stdout.isatty():
464+
cli.log.warn("nfctl does not have a stable CLI interface. Use with caution in scripts.")
465+
else: # output is YAML or JSON
466+
# don't emit INFO messages to stdout because they will break deserialization
467+
cli.log.setLevel(logging.WARN)
462468

463469
organization = use_organization()
464470

@@ -558,16 +564,24 @@ def delete(cli):
558564
cli.log.warn("ignoring name='%s' because this operation applies to the entire network that is already selected", str(cli.args.query))
559565
try:
560566
if cli.args.yes or questions.yesno("confirm delete network '{name}'".format(name=network.name), default=False):
561-
spinner.text = 'deleting {net}'.format(net=network.name)
562-
with spinner:
563-
try:
567+
spinner.text = "deleting network '{net}'".format(net=network.name)
568+
try:
569+
with spinner:
564570
network.delete_network(progress=False, wait=cli.config.delete.wait)
565-
except KeyboardInterrupt as e:
566-
cli.log.debug("wait cancelled by user")
571+
except KeyboardInterrupt as e:
572+
cli.log.debug("wait cancelled by user")
573+
except Exception as e:
574+
cli.log.error("unknown error in %s", e)
575+
exit(1)
576+
else:
577+
cli.log.info(sub('deleting', 'deleted', spinner.text))
567578
else:
568579
cli.echo("not deleting network '{name}'.".format(name=network.name))
569580
except KeyboardInterrupt as e:
570581
cli.log.debug("input cancelled by user")
582+
except Exception as e:
583+
cli.log.error("unknown error in %s", e)
584+
exit(1)
571585
else:
572586
if not cli.args.query:
573587
cli.log.error("need query to select a resource")
@@ -587,15 +601,23 @@ def delete(cli):
587601
try:
588602
if cli.args.yes or questions.yesno("confirm delete {type} '{name}'".format(type=cli.args.resource_type, name=matches[0]['name']), default=False):
589603
spinner.text = "deleting {type} '{name}'".format(type=cli.args.resource_type, name=matches[0]['name'])
590-
with spinner:
591-
try:
604+
try:
605+
with spinner:
592606
network.delete_resource(type=cli.args.resource_type, id=matches[0]['id'])
593-
except KeyboardInterrupt as e:
594-
cli.log.debug("wait cancelled by user")
607+
except KeyboardInterrupt as e:
608+
cli.log.debug("wait cancelled by user")
609+
except Exception as e:
610+
cli.log.error("unknown error in %s", e)
611+
exit(1)
612+
else:
613+
cli.log.info(sub('deleting', 'deleted', spinner.text))
595614
else:
596615
cli.echo("not deleting {type} '{name}'".format(type=cli.args.resource_type, name=matches[0]['name']))
597616
except KeyboardInterrupt as e:
598617
cli.log.debug("input cancelled by user")
618+
except Exception as e:
619+
cli.log.error("unknown error in %s", e)
620+
exit(1)
599621

600622
def use_organization(prompt: bool=True):
601623
"""Assume an identity in an organization."""
@@ -627,6 +649,9 @@ def use_organization(prompt: bool=True):
627649
except KeyboardInterrupt as e:
628650
cli.log.debug("input cancelled by user")
629651
exit(1)
652+
except Exception as e:
653+
cli.log.error("unknown error in %s", e)
654+
exit(1)
630655
try:
631656
organization = Organization(
632657
credentials=cli.config.general.credentials if cli.config.general.credentials else None,
@@ -636,6 +661,10 @@ def use_organization(prompt: bool=True):
636661
proxy=cli.config.general.proxy
637662
)
638663
except PyJWTError as e:
664+
cli.log.error("caught JWT error in %e", e)
665+
exit(1)
666+
except Exception as e:
667+
cli.log.error("unknown error in %s", e)
639668
exit(1)
640669
else:
641670
cli.log.info("not logged in")
@@ -688,7 +717,7 @@ def use_network(organization: object, network: str=None, group: str=None, operat
688717
exit(1)
689718

690719
spinner = cli.spinner(text=str(), spinner='dots12', stream=sys.stderr)
691-
if sys.stdout.isatty():
720+
if sys.stdout.isatty() and cli.log.getEffectiveLevel() <= logging.INFO:
692721
spinner.enabled = True
693722
else:
694723
spinner.enabled = False
@@ -703,25 +732,42 @@ def use_network(organization: object, network: str=None, group: str=None, operat
703732
network.wait_for_statuses(["DELETING","DELETED"],wait=999,progress=False)
704733
except KeyboardInterrupt as e:
705734
cli.log.debug("wait cancelled by user")
735+
except Exception as e:
736+
cli.log.error("unknown error in %s", e)
737+
exit(1)
738+
else:
739+
cli.log.info("network '{net}' deleted".format(net=network_identifier))
706740
elif operation in ['create','read','update']:
707-
spinner.text = 'waiting for {net} to have status PROVISIONED'.format(net=network_identifier)
708-
try:
709-
with spinner:
710-
network.wait_for_status("PROVISIONED",wait=999,progress=False)
711-
except KeyboardInterrupt as e:
712-
cli.log.debug("wait cancelled by user")
741+
if not network.status == 'PROVISIONED':
742+
spinner.text = 'waiting for {net} to have status PROVISIONED'.format(net=network_identifier)
743+
try:
744+
with spinner:
745+
network.wait_for_status("PROVISIONED",wait=999,progress=False)
746+
except KeyboardInterrupt as e:
747+
cli.log.debug("wait cancelled by user")
748+
except Exception as e:
749+
cli.log.error("unknown error in %s", e)
750+
exit(1)
751+
else:
752+
cli.log.info("network '{net}' ready".format(net=network_identifier))
753+
713754
return network, network_group
714755

715756
def edit_object_as_yaml(edit: object):
716757
"""Edit a resource object as YAML and return as object upon exit.
717758
718759
:param obj input: a deserialized (object) to edit and return as yaml
719760
"""
720-
if cli.args.yes:
761+
# unless --yes (config general.yes), if stdout is connected to a terminal
762+
# then open input for editing and send on exit
763+
if not sys.stdout.isatty() or cli.args.yes:
721764
return edit
765+
save_error = False
722766
EDITOR = os.environ.get('NETFOUNDRY_EDITOR',os.environ.get('EDITOR','vim'))
767+
instructions_bytes = "# just exit to confirm, or\n# abort by saving an empty file\n".encode()
768+
edit_bytes = yaml_dumps(edit, default_flow_style=False).encode()
723769
with tempfile.NamedTemporaryFile(suffix=".yml") as tf:
724-
tf.write(yaml_dumps(edit, default_flow_style=False))
770+
tf.write(instructions_bytes + edit_bytes)
725771
tf.flush()
726772
return_code = call(EDITOR.split()+[tf.name])
727773

@@ -732,18 +778,20 @@ def edit_object_as_yaml(edit: object):
732778
edited_object = yaml_loads(edited)
733779
except parser.ParserError as e:
734780
cli.log.error("invalid YAML or JSON: %s", e)
735-
with tempfile.NamedTemporaryFile(suffix=".yml") as tf:
736-
tf.write(edited)
737-
cli.log.warn("your buffer was saved in %s and you may edit and redirect to the same command as stdin or --file", tf.name)
738-
exit(1)
781+
save_error = True
739782
except Exception as e:
740783
cli.log.error("unknown error in %s", e)
741-
exit(1)
784+
save_error = True
742785
else:
743786
return edited_object
744787
else:
745-
cli.log.debug("error editing temporary file")
746-
exit(1)
788+
cli.log.error("editor returned an error")
789+
save_error = True
790+
if save_error:
791+
with tempfile.NamedTemporaryFile(suffix=".yml") as tf:
792+
tf.write(edited.encode())
793+
cli.log.warn("your buffer was saved in %s and you may edit and redirect to the same command as stdin or --file", tf.name)
794+
747795

748796
if __name__ == '__main__':
749797
cli()

0 commit comments

Comments
 (0)