diff --git a/awsshell/app.py b/awsshell/app.py index 31d44f6..d9239c3 100644 --- a/awsshell/app.py +++ b/awsshell/app.py @@ -9,6 +9,8 @@ import logging import sys +import botocore.session + from prompt_toolkit.document import Document from prompt_toolkit.shortcuts import create_eventloop from prompt_toolkit.buffer import Buffer @@ -158,7 +160,17 @@ def __init__(self, output=sys.stdout, err=sys.stderr, loader=None): self._err = err self._wizard_loader = loader if self._wizard_loader is None: - self._wizard_loader = WizardLoader() + session = self._initalize_session() + self._wizard_loader = WizardLoader(session=session) + + def _initalize_session(self): + """Get a session and append the data directory to search paths.""" + session = botocore.session.get_session() + data_loader = session.get_component('data_loader') + shell_root_dir = os.path.dirname(os.path.abspath(__file__)) + data_path = os.path.join(shell_root_dir, 'data') + data_loader.search_paths.append(data_path) + return session def run(self, command, application): """Run the specified wizard. diff --git a/awsshell/data/wizards/2016-01-01/apigateway.json b/awsshell/data/wizards/2016-01-01/apigateway.json new file mode 100644 index 0000000..610f050 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/apigateway.json @@ -0,0 +1,101 @@ +{ + "StartStage": "ApiSourceSwitch", + "Stages": [ + { + "Name": "ApiSourceSwitch", + "Prompt": "What is the source for the new Api?", + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Create new Api", "Stage": "CreateApi"}, + { "Option": "Generate new Api from swagger spec file", "Stage": "NewSwaggerApi"} + ] + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { + "Path": "Stage", + "Key": "CreationType" + }, + "NextStage": { "Type": "Variable", "Name": "CreationType" } + }, + { + "Name": "NewSwaggerApi", + "Prompt": "Enter the path to the JSON swagger spec file", + "Interaction": { "ScreenType": "FilePrompt" }, + "Resolution": { "Key": "SwaggerBlob" }, + "NextStage": { "Type": "Name", "Name": "ImportApiRequest" } + }, + { + "Name": "ImportApiRequest", + "Prompt": "Importing Api from swagger spec...", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "apigateway", + "Operation": "ImportRestApi", + "Parameters": { "failOnWarnings": true }, + "EnvParameters": { "body": "SwaggerBlob" } + } + } + }, + { + "Name": "CreateApi", + "Prompt": "Api Name and Description", + "Retrieval": { + "Type": "Static", + "Resource": { "name": "", "description": "" } + }, + "Interaction": { "ScreenType": "SimplePrompt" }, + "Resolution": { "Key": "Details" }, + "NextStage": { "Type": "Name", "Name": "CloneSwitch" } + }, + { + "Name": "CloneSwitch", + "Prompt": "Create new empty Api or clone from existing?", + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Create new Api", "Stage": "CreateApiRequest" }, + { "Option": "Clone existing Api", "Stage": "GetApiList" } + ] + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "CloneType" }, + "NextStage": { "Type": "Variable", "Name": "CloneType" } + }, + { + "Name": "GetApiList", + "Prompt": "Select an Api to clone", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "apigateway", + "Operation": "GetRestApis" + }, + "Path": "items" + }, + "Interaction": { "ScreenType": "InfoSelect", "Path": "[].name" }, + "Resolution": { "Path": "id", "Key": "ApiId" }, + "NextStage": { "Type": "Name", "Name": "CreateApiRequest" } + }, + { + "Name": "CreateApiRequest", + "Prompt": "Creating new Api...", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "apigateway", + "Operation": "CreateRestApi", + "EnvParameters": { + "cloneFrom": "ApiId", + "name": "Details.name", + "description": "Details.description" + } + } + } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/create-instance-profile.json b/awsshell/data/wizards/2016-01-01/create-instance-profile.json new file mode 100644 index 0000000..2af8d4c --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/create-instance-profile.json @@ -0,0 +1,95 @@ +{ + "StartStage": "NamePrompt", + "Stages": [ + { + "Name": "NamePrompt", + "Prompt": "Enter the Instance Profile name", + + "Retrieval": { + "Type": "Static", + "Resource": {"Name": ""} + }, + + "Interaction": { "ScreenType": "SimplePrompt" }, + "Resolution": { "Path": "Name", "Key": "ProfileName" }, + "NextStage": { "Type": "Name", "Name": "CreateProfile" } + }, + { + "Name": "CreateProfile", + "Prompt": "Creating Profile...", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "iam", + "Operation": "CreateInstanceProfile", + "EnvParameters": { + "InstanceProfileName": "ProfileName" + } + }, + "Path": "InstanceProfile" + }, + "Resolution": { "Key": "Profile" }, + "NextStage": { "Type": "Name", "Name": "AddRoleSwitch" } + }, + { + "Name": "AddRoleSwitch", + "Prompt": "Add a role to this profile?", + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Yes", "Stage": "SelectRole"}, + { "Option": "No", "Stage": "GetRole"} + ] + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "RoleSwitch" }, + "NextStage": { "Type": "Variable", "Name": "RoleSwitch" } + }, + { + "Name": "SelectRole", + "Prompt": "Select Role to add:", + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "iam", + "Operation": "ListRoles" + }, + "Path": "Roles[].RoleName" + }, + "Interaction": { "ScreenType": "SimpleSelect" }, + "Resolution": { "Key": "RoleName" }, + "NextStage": { "Type": "Name", "Name": "AddRole" } + }, + { + "Name": "AddRole", + "Prompt": "Adding role...", + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "iam", + "Operation": "AddRoleToInstanceProfile", + "EnvParameters": { + "RoleName": "RoleName", + "InstanceProfileName": "Profile.InstanceProfileName" + } + } + }, + "NextStage": { "Type": "Name", "Name": "GetRole" } + }, + { + "Name": "GetRole", + "Prompt": "Getting role...", + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "iam", + "Operation": "GetInstanceProfile", + "EnvParameters": { + "InstanceProfileName": "Profile.InstanceProfileName" + } + } + } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/create-security-group.json b/awsshell/data/wizards/2016-01-01/create-security-group.json new file mode 100644 index 0000000..0f2b6b9 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/create-security-group.json @@ -0,0 +1,74 @@ +{ + "StartStage": "GroupDetails", + "Stages": [ + { + "Name": "GroupDetails", + "Prompt": "Please enter the details for this group: ", + + "Retrieval": { + "Type": "Static", + "Resource": {"Name": "", "Description": ""} + }, + + "Interaction": { "ScreenType": "SimplePrompt" }, + "Resolution": { "Key": "Details" }, + "NextStage": { "Type": "Name", "Name": "SelectVPCSwitch" } + }, + { + "Name": "SelectVPCSwitch", + "Prompt": "Would you like to specify a VPC? ", + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Use Default", "Stage": "CreateGroupRequest" }, + { "Option": "Select a VPC", "Stage": "GetVPC" } + ] + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "VPCSwitch" }, + "NextStage": { "Type": "Variable", "Name": "VPCSwitch" } + }, + { + "Name": "GetVPC", + "Prompt": "Getting VPC to use...", + + "Retrieval": { "Type": "Wizard", "Resource": "get-vpc" }, + "Resolution": { "Path": "VpcId", "Key": "VpcId" }, + "NextStage": { "Type": "Name", "Name": "CreateGroupRequest" } + }, + { + "Name": "CreateGroupRequest", + "Prompt": "Creating Security Group...", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "CreateSecurityGroup", + "Parameters": { "DryRun": false }, + "EnvParameters": { + "VpcId": "VpcId", + "GroupName": "Details.Name", + "Description": "Details.Description" + } + } + }, + "Resolution": { "Path": "GroupId", "Key": "GroupId" }, + "NextStage": { "Type": "Name", "Name": "GetGroup" } + }, + { + "Name": "GetGroup", + "Prompt": "Getting Security Group...", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "DescribeSecurityGroups", + "EnvParameters": { "GroupIds": "GroupId | [@]" } + } + }, + "Resolution": { "Path": "SecurityGroups[0]", "Key": "Group" } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/create-subnet.json b/awsshell/data/wizards/2016-01-01/create-subnet.json new file mode 100644 index 0000000..88eeb86 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/create-subnet.json @@ -0,0 +1,73 @@ +{ + "StartStage": "CIDRPrompt", + "Stages": [ + { + "Name": "CIDRPrompt", + "Prompt": "Enter subnet IP address block (e.g., 10.0.0.0/24)", + + "Retrieval": { + "Type": "Static", + "Resource": {"CIDR": ""} + }, + + "Interaction": { "ScreenType": "SimplePrompt" }, + "Resolution": { "Path": "CIDR", "Key": "CIDR" }, + "NextStage": { "Type": "Name", "Name": "ZoneSwitch" } + }, + { + "Name": "ZoneSwitch", + "Prompt": "Would you like to specify an availability zone?", + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "No Preference", "Stage": "GetVPC"}, + { "Option": "Select availability zone", "Stage": "SelectZone"} + ] + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "ZoneSwitch" }, + "NextStage": { "Type": "Variable", "Name": "ZoneSwitch" } + }, + { + "Name": "SelectZone", + "Prompt": "Select the desired zone: ", + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "DescribeAvailabilityZones" + }, + "Path": "AvailabilityZones" + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].ZoneName" }, + "Resolution": { "Path": "ZoneName", "Key": "ZoneName" }, + "NextStage": { "Type": "Name", "Name": "GetVPC" } + }, + { + "Name": "GetVPC", + "Prompt": "Getting VPC to use...", + + "Retrieval": { "Type": "Wizard", "Resource": "get-vpc" }, + "Resolution": { "Path": "VpcId", "Key": "VpcId" }, + "NextStage": { "Type": "Name", "Name": "CreateSubnet" } + }, + { + "Name": "CreateSubnet", + "Prompt": "Creating Subnet...", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "CreateSubnet", + "Parameters": { "DryRun": false }, + "EnvParameters": { + "VpcId": "VpcId", + "CidrBlock": "CIDR", + "AvailabilityZone": "ZoneName" + } + } + } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/create-vpc.json b/awsshell/data/wizards/2016-01-01/create-vpc.json new file mode 100644 index 0000000..073209b --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/create-vpc.json @@ -0,0 +1,46 @@ +{ + "StartStage": "CIDRPrompt", + "Stages": [ + { + "Name": "CIDRPrompt", + "Prompt": "Enter VPC IP address block (e.g., 10.0.0.0/16)", + + "Retrieval": { + "Type": "Static", + "Resource": {"CIDR": ""} + }, + + "Interaction": { "ScreenType": "SimplePrompt" }, + "Resolution": { "Path": "CIDR", "Key": "CIDR" }, + "NextStage": { "Type": "Name", "Name": "SelectTenancy" } + }, + { + "Name": "SelectTenancy", + "Prompt": "Should this VPC use the default tenancy or force dedicated?", + "Retrieval": { + "Type": "Static", + "Resource": [ "default", "dedicated"] + }, + "Interaction": { "ScreenType": "SimpleSelect" }, + "Resolution": { "Key": "Tenancy" }, + "NextStage": { "Type": "Name", "Name": "CreateVPC" } + }, + { + "Name": "CreateVPC", + "Prompt": "Creating VPC...", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "CreateVpc", + "Parameters": { "DryRun": false }, + "EnvParameters": { + "CidrBlock": "CIDR", + "InstanceTenancy": "Tenancy" + } + } + } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/get-instance-profile.json b/awsshell/data/wizards/2016-01-01/get-instance-profile.json new file mode 100644 index 0000000..e3ce2bc --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/get-instance-profile.json @@ -0,0 +1,44 @@ +{ + "StartStage": "GetProfileSwitch", + "Stages": [ + { + "Name": "GetProfileSwitch", + "Prompt": "Select a source for the Instance Profile: ", + + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Create new Profile", "Stage": "CreateProfile" }, + { "Option": "Select existing Profile", "Stage": "SelectProfile" } + ] + }, + + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "ProfileSource" }, + "NextStage": { "Type": "Variable", "Name": "ProfileSource" } + }, + { + "Name": "CreateProfile", + "Prompt": "Delegate to create Profile", + "Retrieval": { + "Type": "Wizard", + "Resource": "create-instance-profile", + "Path": "InstanceProfile" + } + }, + { + "Name": "SelectProfile", + "Prompt": "Select a Profile: ", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "iam", + "Operation": "ListInstanceProfiles" + }, + "Path": "InstanceProfiles" + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].InstanceProfileName" } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/get-policy.json b/awsshell/data/wizards/2016-01-01/get-policy.json new file mode 100644 index 0000000..d8d85e7 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/get-policy.json @@ -0,0 +1,30 @@ +{ + "StartStage": "GetPolicySwitch", + "Stages": [ + { + "Name": "GetPolicySwitch", + "Prompt": "Select a source for the policy: ", + + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Create new policy", "Stage": "CreatePolicy" }, + { "Option": "Select managed policy", "Stage": "SelectPolicy" } + ] + }, + + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "PolicySource" }, + "NextStage": { "Type": "Variable", "Name": "PolicySource" } + }, + { + "Name": "CreatePolicy", + "Prompt": "Delegate to create policy" + }, + { + "Name": "SelectPolicy", + "Prompt": "Delegate to select policy", + "Retrieval": { "Type": "Wizard", "Resource": "select-policy" } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/get-security-group.json b/awsshell/data/wizards/2016-01-01/get-security-group.json new file mode 100644 index 0000000..33805a5 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/get-security-group.json @@ -0,0 +1,43 @@ +{ + "StartStage": "GetGroupSwitch", + "Stages": [ + { + "Name": "GetGroupSwitch", + "Prompt": "Select a source for the Security Group: ", + + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Create new Security Group", "Stage": "CreateGroup" }, + { "Option": "Select existing Security Group", "Stage": "SelectGroup" } + ] + }, + + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "GroupSource" }, + "NextStage": { "Type": "Variable", "Name": "GroupSource" } + }, + { + "Name": "CreateGroup", + "Prompt": "Delegate to create Security Group", + "Retrieval": { + "Type": "Wizard", + "Resource": "create-security-group" + } + }, + { + "Name": "SelectGroup", + "Prompt": "Select a Security Group: ", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "DescribeSecurityGroups" + }, + "Path": "SecurityGroups" + }, + "Interaction": { "ScreenType": "InfoSelect", "Path": "[].GroupId" } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/get-subnet.json b/awsshell/data/wizards/2016-01-01/get-subnet.json new file mode 100644 index 0000000..b891348 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/get-subnet.json @@ -0,0 +1,44 @@ +{ + "StartStage": "GetSubnetSwitch", + "Stages": [ + { + "Name": "GetSubnetSwitch", + "Prompt": "Select a source for the Subnet: ", + + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Create new Subnet", "Stage": "CreateSubnet" }, + { "Option": "Select existing Subnet", "Stage": "SelectSubnet" } + ] + }, + + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "SubnetSource" }, + "NextStage": { "Type": "Variable", "Name": "SubnetSource" } + }, + { + "Name": "CreateSubnet", + "Prompt": "Delegate to create subnet", + "Retrieval": { + "Type": "Wizard", + "Resource": "create-subnet", + "Path": "Subnet" + } + }, + { + "Name": "SelectSubnet", + "Prompt": "Select a Subnet: ", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "DescribeSubnets" + }, + "Path": "Subnets" + }, + "Interaction": { "ScreenType": "InfoSelect", "Path": "[].SubnetId" } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/get-vpc.json b/awsshell/data/wizards/2016-01-01/get-vpc.json new file mode 100644 index 0000000..c137299 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/get-vpc.json @@ -0,0 +1,44 @@ +{ + "StartStage": "GetVPCSwitch", + "Stages": [ + { + "Name": "GetVPCSwitch", + "Prompt": "Select a source for the VPC: ", + + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Create new VPC", "Stage": "CreateVPC" }, + { "Option": "Select existing VPC", "Stage": "SelectVPC" } + ] + }, + + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "VPCSource" }, + "NextStage": { "Type": "Variable", "Name": "VPCSource" } + }, + { + "Name": "CreateVPC", + "Prompt": "Delegate to create VPC", + "Retrieval": { + "Type": "Wizard", + "Resource": "create-vpc", + "Path": "Vpc" + } + }, + { + "Name": "SelectVPC", + "Prompt": "Select a VPC: ", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "DescribeVpcs" + }, + "Path": "Vpcs" + }, + "Interaction": { "ScreenType": "InfoSelect", "Path": "[].VpcId" } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/launch-instance.json b/awsshell/data/wizards/2016-01-01/launch-instance.json new file mode 100644 index 0000000..556868d --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/launch-instance.json @@ -0,0 +1,161 @@ +{ + "StartStage": "GetAMI", + "Stages": [ + { + "Name": "GetAMI", + "Prompt": "Getting AMI to use...", + + "Retrieval": { "Type": "Wizard", "Resource": "select-ami" }, + "Resolution": { "Path": "ImageId", "Key": "ImageId" }, + "NextStage": { "Type": "Name", "Name": "InstanceDetails" } + }, + + { + "Name": "InstanceDetails", + "Prompt": "Enter minimum and maximium number of instances", + "Retrieval": { + "Type": "Static", + "Resource": { "MinCount": "", "MaxCount": "" } + }, + "Interaction": { "ScreenType": "SimplePrompt" }, + "Resolution": { "Key": "Details" }, + "NextStage": { "Type": "Name", "Name": "SelectInstanceType" } + }, + + { + "Name": "SelectInstanceType", + "Prompt": "Select Instance Type: ", + "Retrieval": { + "Type": "Static", + "Resource": ["t2.nano", "t2.micro", "t2.small", "t2.medium", "t2.large"] + }, + "Interaction": { "ScreenType": "SimpleSelect" }, + "Resolution": { "Key": "InstanceType" }, + "NextStage": { "Type": "Name", "Name": "SelectProfileSwitch" } + }, + + { + "Name": "SelectProfileSwitch", + "Prompt": "Would you like to specify an IAM Instance Profile? ", + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Do not use profile", "Stage": "SelectSubnetSwitch"}, + { "Option": "Select a profile", "Stage": "GetInstanceProfile"} + ] + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "ProfileSwitch" }, + "NextStage": { "Type": "Variable", "Name": "ProfileSwitch" } + }, + { + "Name": "GetInstanceProfile", + "Prompt": "Getting Instance Profile to use...", + + "Retrieval": { "Type": "Wizard", "Resource": "get-instance-profile" }, + "Resolution": { "Key": "Profile" }, + "NextStage": { "Type": "Name", "Name": "SelectSubnetSwitch" } + }, + + { + "Name": "SelectSubnetSwitch", + "Prompt": "Would you like to specify a Subnet? ", + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Use Default", "Stage": "SecurityGroupSwitch"}, + { "Option": "Select a Subnet", "Stage": "GetSubnet"} + ] + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "SubnetSwitch" }, + "NextStage": { "Type": "Variable", "Name": "SubnetSwitch" } + }, + { + "Name": "GetSubnet", + "Prompt": "Getting Subnet to use...", + + "Retrieval": { "Type": "Wizard", "Resource": "get-subnet" }, + "Resolution": { "Path": "SubnetId", "Key": "SubnetId" }, + "NextStage": { "Type": "Name", "Name": "SecurityGroupSwitch" } + }, + + { + "Name": "SecurityGroupSwitch", + "Prompt": "Would you like to specify a Security Group? ", + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Use Default", "Stage": "KeySwitch"}, + { "Option": "Select a Security Group", "Stage": "GetSecurityGroup"} + ] + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "GroupSwitch" }, + "NextStage": { "Type": "Variable", "Name": "GroupSwitch" } + }, + { + "Name": "GetSecurityGroup", + "Prompt": "Getting Security Group to use...", + + "Retrieval": { "Type": "Wizard", "Resource": "get-security-group" }, + "Resolution": { "Path": "GroupId", "Key": "GroupId" }, + "NextStage": { "Type": "Name", "Name": "KeySwitch" } + }, + + { + "Name": "KeySwitch", + "Prompt": "Would you like to attach a Key Pair?", + "Retrieval": { + "Type": "Static", + "Resource": [ + { "Option": "Do not attach", "Stage": "LaunchInstance"}, + { "Option": "Select a Key Pair", "Stage": "GetKeyPair"} + ] + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].Option" }, + "Resolution": { "Path": "Stage", "Key": "GroupSwitch" }, + "NextStage": { "Type": "Variable", "Name": "GroupSwitch" } + }, + { + "Name": "GetKeyPair", + "Prompt": "Select Key Pair to use: ", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "DescribeKeyPairs" + }, + "Path": "KeyPairs" + }, + "Interaction": { "ScreenType": "SimpleSelect", "Path": "[].KeyName" }, + "Resolution": { "Path": "KeyName", "Key": "KeyName" }, + "NextStage": { "Type": "Name", "Name": "LaunchInstance" } + }, + + { + "Name": "LaunchInstance", + "Prompt": "Launching instance...", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "RunInstances", + "Parameters": { "DryRun": false }, + "EnvParameters": { + "ImageId": "ImageId", + "SubnetId": "SubnetId", + "InstanceType": "InstanceType", + "MinCount": "Details.MinCount", + "MaxCount": "Details.MaxCount", + "IamInstanceProfile": "Profile | {Name: @.InstanceProfileName}", + "SecurityGroupIds": "GroupId | [@]", + "KeyName": "KeyName" + } + } + } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/publish-message.json b/awsshell/data/wizards/2016-01-01/publish-message.json new file mode 100644 index 0000000..179e2a1 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/publish-message.json @@ -0,0 +1,67 @@ +{ + "StartStage": "GetTopic", + "Stages": [ + { + "Name": "GetTopic", + "Prompt": "Select the topic to publish to:", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "sns", + "Operation": "ListTopics" + }, + "Path": "Topics" + }, + + "Interaction": { + "ScreenType": "SimpleSelect", + "Path": "[].TopicArn" + }, + + "Resolution": { + "Key": "TopicArn", + "Path": "TopicArn" + }, + + "NextStage": { "Type": "Name", "Name": "MessageForm" } + }, + + { + "Name": "MessageForm", + "Prompt": "Provide the message details.", + + "Retrieval": { + "Type": "Static", + "Resource": { + "Subject": "", + "Body": "" + } + }, + + "Interaction": { "ScreenType": "SimplePrompt" }, + + "Resolution": { "Key": "MessageDetails" }, + + "NextStage": { "Type": "Name", "Name": "PublishMessage" } + }, + + { + "Name": "PublishMessage", + "Prompt": "Publishing message...", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "sns", + "Operation": "Publish", + "EnvParameters": { + "TopicArn": "TopicArn", + "Message": "MessageDetails.Body", + "Subject": "MessageDetails.Subject" + } + } + } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/select-ami.json b/awsshell/data/wizards/2016-01-01/select-ami.json new file mode 100644 index 0000000..ca929b2 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/select-ami.json @@ -0,0 +1,20 @@ +{ + "StartStage": "GetAmiList", + "Stages": [ + { + "Name": "GetAmiList", + "Prompt": "Select an AMI: ", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "ec2", + "Operation": "DescribeImages", + "Parameters": { "Owners": ["self"] } + }, + "Path": "Images" + }, + "Interaction": { "ScreenType": "FuzzySelect", "Path": "[].Name" } + } + ] +} diff --git a/awsshell/data/wizards/2016-01-01/select-policy.json b/awsshell/data/wizards/2016-01-01/select-policy.json new file mode 100644 index 0000000..ff60c01 --- /dev/null +++ b/awsshell/data/wizards/2016-01-01/select-policy.json @@ -0,0 +1,19 @@ +{ + "StartStage": "GetPolicyList", + "Stages": [ + { + "Name": "GetPolicyList", + "Prompt": "Select a policy: ", + + "Retrieval": { + "Type": "Request", + "Resource": { + "Service": "iam", + "Operation": "ListPolicies" + }, + "Path": "Policies" + }, + "Interaction": { "ScreenType": "FuzzySelect", "Path": "[].PolicyName" } + } + ] +} diff --git a/awsshell/interaction.py b/awsshell/interaction.py index 3d7957f..4814a68 100644 --- a/awsshell/interaction.py +++ b/awsshell/interaction.py @@ -77,12 +77,14 @@ def __init__(self, model, prompt_msg, prompter=select_prompt): super(SimpleSelect, self).__init__(model, prompt_msg) self._prompter = prompter - def execute(self, data): + def execute(self, data, show_meta=False): if not isinstance(data, list) or len(data) < 1: raise InteractionException('SimpleSelect expects a non-empty list') if self._model.get('Path') is not None: display_data = jmespath.search(self._model['Path'], data) - result = self._prompter('%s ' % self.prompt, display_data) + options_meta = data if show_meta else None + result = self._prompter('%s ' % self.prompt, display_data, + options_meta=options_meta) (selected, index) = result return data[index] else: @@ -90,6 +92,17 @@ def execute(self, data): return selected +class InfoSelect(SimpleSelect): + """Display a list of options with meta information. + + Small extension of :class:`SimpleSelect` that turns the show_meta flag on + to display what the complete object looks like rendered as json in a pane + below the prompt. + """ + def execute(self, data): + return super(InfoSelect, self).execute(data, show_meta=True) + + class SimplePrompt(Interaction): """Prompt the user to type in responses for each field. @@ -174,6 +187,7 @@ class InteractionLoader(object): Interaction objects can be instantiated from their corresponding str. """ _INTERACTIONS = { + 'InfoSelect': InfoSelect, 'FuzzySelect': FuzzySelect, 'SimpleSelect': SimpleSelect, 'SimplePrompt': SimplePrompt, diff --git a/awsshell/selectmenu.py b/awsshell/selectmenu.py index 2f86a3d..895d7c0 100644 --- a/awsshell/selectmenu.py +++ b/awsshell/selectmenu.py @@ -1,4 +1,3 @@ -import json from pygments.lexers import find_lexer_class from prompt_toolkit.keys import Keys from prompt_toolkit.token import Token @@ -21,6 +20,7 @@ from prompt_toolkit.layout import Window, HSplit, FloatContainer, Float from prompt_toolkit.layout.containers import ScrollOffsets, \ ConditionalContainer +from awsshell.utils import format_json """An implementation of a selection menu using prompt toolkit. @@ -261,8 +261,7 @@ def return_selection(cli, buf): def selection_changed(cli): index = self.menu_control.get_index() info = options_meta[index] - formatted_info = json.dumps(info, indent=4, sort_keys=True, - ensure_ascii=False) + formatted_info = format_json(info) buffers['INFO'].text = formatted_info default_buf.on_text_changed += selection_changed diff --git a/awsshell/utils.py b/awsshell/utils.py index 5e9dab7..60c9324 100644 --- a/awsshell/utils.py +++ b/awsshell/utils.py @@ -6,9 +6,11 @@ import tempfile import uuid import logging +import json import awscli +from awscli.utils import json_encoder from awsshell.compat import HTMLParser @@ -142,3 +144,8 @@ def force_unicode(obj, encoding='utf8'): if not isinstance(obj, six.text_type): obj = _attempt_decode(obj, encoding) return obj + + +def format_json(response): + return json.dumps(response, indent=4, default=json_encoder, + ensure_ascii=False, sort_keys=True) diff --git a/awsshell/wizard.py b/awsshell/wizard.py index 6effa53..fdbaa78 100644 --- a/awsshell/wizard.py +++ b/awsshell/wizard.py @@ -1,6 +1,7 @@ +import six import sys import copy -import json +import logging import jmespath import botocore.session @@ -8,13 +9,97 @@ from botocore.exceptions import BotoCoreError, ClientError from awsshell.resource import index -from awsshell.utils import force_unicode +from awsshell.utils import force_unicode, format_json from awsshell.selectmenu import select_prompt from awsshell.interaction import InteractionLoader, InteractionException from prompt_toolkit.shortcuts import confirm +LOG = logging.getLogger(__name__) + + +class ParamCoercion(object): + """This class coerces string parameters into the correct type. + + By default this converts strings to numerical values if the input + parameters model indicates that the field should be a number. This is to + compensate for the fact that values taken in from prompts will always be + strings and avoids having to create specific interactions for simple + conversions or having to specify the type in the wizard specification. + """ + + _DEFAULT_DICT = { + 'integer': int, + 'float': float, + 'double': float, + 'long': int + } + + def __init__(self, type_dict=_DEFAULT_DICT): + """Initialize a ParamCoercion object. + + :type type_dict: dict + :param type_dict: (Optional) A dictionary of converstions. Keys are + strings representing the shape type name and the values are callables + that given a string will return an instance of an appropriate type for + that shape type. Defaults to only coerce numbers. + """ + self._type_dict = type_dict + + def coerce(self, params, shape): + """Coerce the params according to the given shape. + + :type params: dict + :param params: The parameters to be given to an operation call. + + :type shape: :class:`botocore.model.Shape` + :param shape: The input shape for the desired operation. + + :rtype: dict + :return: The coerced version of the params. + """ + name = shape.type_name + if isinstance(params, dict) and name == 'structure': + return self._coerce_structure(params, shape) + elif isinstance(params, dict) and name == 'map': + return self._coerce_map(params, shape) + elif isinstance(params, (list, tuple)) and name == 'list': + return self._coerce_list(params, shape) + elif isinstance(params, six.string_types) and name in self._type_dict: + target_type = self._type_dict[shape.type_name] + return self._coerce_field(params, target_type) + return params + + def _coerce_structure(self, params, shape): + members = shape.members + coerced = {} + for param in members: + if param in params: + coerced[param] = self.coerce(params[param], members[param]) + return coerced + + def _coerce_map(self, params, shape): + coerced = {} + for key, value in params.items(): + coerced_key = self.coerce(key, shape.key) + coerced[coerced_key] = self.coerce(value, shape.value) + return coerced + + def _coerce_list(self, list_param, shape): + member_shape = shape.member + coerced_list = [] + for item in list_param: + coerced_list.append(self.coerce(item, member_shape)) + return coerced_list + + def _coerce_field(self, value, target_type): + try: + return target_type(value) + except ValueError: + return value + + def stage_error_handler(error, stages, confirm=confirm, prompt=select_prompt): managed_errors = ( ClientError, @@ -261,6 +346,8 @@ def _handle_request_retrieval(self): self._env.resolve_parameters(req.get('EnvParameters', {})) # union of parameters and env_parameters, conflicts favor env params parameters = dict(parameters, **env_parameters) + model = client.meta.service_model.operation_model(req['Operation']) + parameters = ParamCoercion().coerce(parameters, model.input_shape) # if the operation supports pagination, load all results upfront if client.can_paginate(operation_name): # get paginator and create iterator @@ -344,7 +431,7 @@ def __init__(self): self._variables = {} def __str__(self): - return json.dumps(self._variables, indent=4, sort_keys=True) + return format_json(self._variables) def update(self, environment): assert isinstance(environment, Environment) @@ -384,5 +471,10 @@ def resolve_parameters(self, keys): """ resolved_dict = {} for key in keys: - resolved_dict[key] = self.retrieve(keys[key]) + retrieved = self.retrieve(keys[key]) + if retrieved is not None: + resolved_dict[key] = retrieved + else: + LOG.debug("Query failed (%s), dropped key: %s", keys[key], key) + return resolved_dict diff --git a/tests/unit/test_interaction.py b/tests/unit/test_interaction.py index 722f3fe..853ad68 100644 --- a/tests/unit/test_interaction.py +++ b/tests/unit/test_interaction.py @@ -6,7 +6,7 @@ from prompt_toolkit.contrib.validators.base import Validator, ValidationError from awsshell.interaction import InteractionLoader, InteractionException from awsshell.interaction import SimpleSelect, SimplePrompt, FilePrompt -from awsshell.interaction import FuzzyCompleter, FuzzySelect +from awsshell.interaction import FuzzyCompleter, FuzzySelect, InfoSelect @pytest.fixture @@ -88,12 +88,13 @@ def test_simple_select(): assert xformed == options[1] -def test_simple_select_with_path(): +@pytest.mark.parametrize('selector', [SimpleSelect, InfoSelect]) +def test_simple_select_with_path(selector): # Verify that SimpleSelect calls prompt and it returns the corresponding # item derived from the path. prompt = mock.Mock() model = {'Path': '[].a'} - simple_selector = SimpleSelect(model, 'Promptingu', prompt) + simple_selector = selector(model, 'Promptingu', prompt) options = [{'a': '1', 'b': 'one'}, {'a': '2', 'b': 'two'}] prompt.return_value = ('2', 1) xformed = simple_selector.execute(options) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index c243f1e..281a7b1 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,6 +1,7 @@ from tests import unittest import os import tempfile +import datetime import shutil import six import pytest @@ -10,6 +11,7 @@ from awsshell.utils import FileReadError from awsshell.utils import temporary_file from awsshell.utils import force_unicode +from awsshell.utils import format_json class TestFSLayer(unittest.TestCase): @@ -127,3 +129,8 @@ def test_force_unicode_recursion(): assert isinstance(clean_obj['b']['str'], six.text_type) assert clean_obj['c'] is obj['c'] assert obj == clean_obj + + +def test_format_json(): + data = {'Key': datetime.datetime(2016, 12, 12)} + assert format_json(data) == '{\n "Key": "2016-12-12T00:00:00"\n}' diff --git a/tests/unit/test_wizard.py b/tests/unit/test_wizard.py index 28ddef6..78807d3 100644 --- a/tests/unit/test_wizard.py +++ b/tests/unit/test_wizard.py @@ -3,9 +3,10 @@ import botocore.session from botocore.loaders import Loader +from botocore import model from botocore.session import Session from awsshell.utils import FileReadError -from awsshell.wizard import stage_error_handler +from awsshell.wizard import stage_error_handler, ParamCoercion from awsshell.interaction import InteractionException from botocore.exceptions import ClientError, BotoCoreError from awsshell.wizard import Environment, WizardLoader, WizardException @@ -40,6 +41,12 @@ def test_resolve_parameters(): assert resolved == {'a': 'Nice', 'b': 'v'} +def test_resolve_parameters_drops_none(env): + keys = {'a': 'bad.query', 'b': 'env_var.epic'} + resolved = env.resolve_parameters(keys) + assert resolved == {'b': 'nice'} + + @pytest.fixture def loader(wizard_spec): session = mock.Mock() @@ -377,3 +384,60 @@ def test_stage_exception_handler_other(error_class): err = error_class() res = stage_error_handler(err, ['stage'], confirm=confirm, prompt=prompt) assert res is None + + +@pytest.fixture +def test_shape(): + shapes = { + "TestShape": { + "type": "structure", + "members": { + "Huge": {"shape": "Long"}, + "Map": {"shape": "TestMap"}, + "Scale": {"shape": "Double"}, + "Count": {"shape": "Integer"}, + "Items": {"shape": "TestList"} + } + }, + "TestList": { + "type": "list", + "member": { + "shape": "Float" + } + }, + "TestMap": { + "type": "map", + "key": {"shape": "Double"}, + "value": {"shape": "Integer"} + }, + "Long": {"type": "long"}, + "Float": {"type": "float"}, + "Double": {"type": "double"}, + "String": {"type": "string"}, + "Integer": {"type": "integer"} + } + return model.ShapeResolver(shapes).get_shape_by_name('TestShape') + + +def test_param_coercion_numbers(test_shape): + # verify coercion will convert strings to numbers according to shape + params = { + "Count": "5", + "Scale": "2.3", + "Items": ["5", "3.14"], + "Huge": "92233720368547758070", + "Map": {"2": "12"} + } + coerced = ParamCoercion().coerce(params, test_shape) + assert isinstance(coerced['Count'], int) + assert isinstance(coerced['Scale'], float) + assert all(isinstance(item, float) for item in coerced['Items']) + assert coerced['Map'][2] == 12 + assert coerced['Huge'] == 92233720368547758070 + + +def test_param_coercion_failure(test_shape): + # verify coercion leaves the field the same when it fails + params = {"Count": "fifty"} + coerced = ParamCoercion().coerce(params, test_shape) + assert coerced["Count"] == params["Count"]