forger 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +16 -0
  3. data/.gitmodules +0 -0
  4. data/.rspec +3 -0
  5. data/CHANGELOG.md +147 -0
  6. data/Gemfile +6 -0
  7. data/Gemfile.lock +136 -0
  8. data/Guardfile +19 -0
  9. data/LICENSE.txt +22 -0
  10. data/README.md +249 -0
  11. data/Rakefile +6 -0
  12. data/docs/example/.env +2 -0
  13. data/docs/example/.env.development +2 -0
  14. data/docs/example/.env.production +3 -0
  15. data/docs/example/app/scripts/hello.sh +3 -0
  16. data/docs/example/app/user-data/bootstrap.sh +35 -0
  17. data/docs/example/config/development.yml +8 -0
  18. data/docs/example/profiles/default.yml +11 -0
  19. data/docs/example/profiles/spot.yml +20 -0
  20. data/exe/forger +14 -0
  21. data/forger.gemspec +38 -0
  22. data/lib/forger.rb +29 -0
  23. data/lib/forger/ami.rb +10 -0
  24. data/lib/forger/aws_service.rb +7 -0
  25. data/lib/forger/base.rb +42 -0
  26. data/lib/forger/clean.rb +13 -0
  27. data/lib/forger/cleaner.rb +5 -0
  28. data/lib/forger/cleaner/ami.rb +45 -0
  29. data/lib/forger/cli.rb +67 -0
  30. data/lib/forger/command.rb +67 -0
  31. data/lib/forger/completer.rb +161 -0
  32. data/lib/forger/completer/script.rb +6 -0
  33. data/lib/forger/completer/script.sh +10 -0
  34. data/lib/forger/config.rb +20 -0
  35. data/lib/forger/core.rb +51 -0
  36. data/lib/forger/create.rb +155 -0
  37. data/lib/forger/create/error_messages.rb +58 -0
  38. data/lib/forger/create/params.rb +106 -0
  39. data/lib/forger/dotenv.rb +30 -0
  40. data/lib/forger/help.rb +9 -0
  41. data/lib/forger/help/ami.md +13 -0
  42. data/lib/forger/help/clean/ami.md +22 -0
  43. data/lib/forger/help/compile.md +5 -0
  44. data/lib/forger/help/completion.md +22 -0
  45. data/lib/forger/help/completion_script.md +3 -0
  46. data/lib/forger/help/create.md +7 -0
  47. data/lib/forger/help/upload.md +10 -0
  48. data/lib/forger/help/wait/ami.md +12 -0
  49. data/lib/forger/hook.rb +33 -0
  50. data/lib/forger/profile.rb +64 -0
  51. data/lib/forger/script.rb +46 -0
  52. data/lib/forger/script/compile.rb +40 -0
  53. data/lib/forger/script/compress.rb +62 -0
  54. data/lib/forger/script/templates/ami_creation.sh +12 -0
  55. data/lib/forger/script/templates/auto_terminate.sh +11 -0
  56. data/lib/forger/script/templates/auto_terminate_after_timeout.sh +5 -0
  57. data/lib/forger/script/templates/cloudwatch.sh +3 -0
  58. data/lib/forger/script/templates/extract_aws_ec2_scripts.sh +48 -0
  59. data/lib/forger/script/upload.rb +99 -0
  60. data/lib/forger/scripts/auto_terminate.sh +14 -0
  61. data/lib/forger/scripts/auto_terminate/after_timeout.sh +18 -0
  62. data/lib/forger/scripts/auto_terminate/functions.sh +130 -0
  63. data/lib/forger/scripts/auto_terminate/functions/amazonlinux2.sh +10 -0
  64. data/lib/forger/scripts/auto_terminate/functions/ubuntu.sh +11 -0
  65. data/lib/forger/scripts/auto_terminate/setup.sh +31 -0
  66. data/lib/forger/scripts/cloudwatch.sh +24 -0
  67. data/lib/forger/scripts/cloudwatch/configure.sh +84 -0
  68. data/lib/forger/scripts/cloudwatch/install.sh +3 -0
  69. data/lib/forger/scripts/cloudwatch/install/amazonlinux2.sh +4 -0
  70. data/lib/forger/scripts/cloudwatch/install/ubuntu.sh +23 -0
  71. data/lib/forger/scripts/cloudwatch/service.sh +3 -0
  72. data/lib/forger/scripts/cloudwatch/service/amazonlinux2.sh +11 -0
  73. data/lib/forger/scripts/cloudwatch/service/ubuntu.sh +8 -0
  74. data/lib/forger/scripts/shared/functions.sh +78 -0
  75. data/lib/forger/setting.rb +52 -0
  76. data/lib/forger/template.rb +13 -0
  77. data/lib/forger/template/context.rb +32 -0
  78. data/lib/forger/template/helper.rb +17 -0
  79. data/lib/forger/template/helper/ami_helper.rb +33 -0
  80. data/lib/forger/template/helper/core_helper.rb +127 -0
  81. data/lib/forger/template/helper/partial_helper.rb +71 -0
  82. data/lib/forger/template/helper/script_helper.rb +53 -0
  83. data/lib/forger/template/helper/ssh_key_helper.rb +21 -0
  84. data/lib/forger/version.rb +3 -0
  85. data/lib/forger/wait.rb +12 -0
  86. data/lib/forger/waiter.rb +5 -0
  87. data/lib/forger/waiter/ami.rb +61 -0
  88. data/spec/fixtures/demo_project/config/settings.yml +22 -0
  89. data/spec/fixtures/demo_project/config/test.yml +9 -0
  90. data/spec/fixtures/demo_project/profiles/default.yml +33 -0
  91. data/spec/lib/cli_spec.rb +41 -0
  92. data/spec/lib/params_spec.rb +71 -0
  93. data/spec/spec_helper.rb +33 -0
  94. metadata +354 -0
@@ -0,0 +1,161 @@
1
+ =begin
2
+ Code Explanation:
3
+
4
+ There are 3 types of things to auto-complete:
5
+
6
+ 1. command: the command itself
7
+ 2. parameters: command parameters.
8
+ 3. options: command options
9
+
10
+ Here's an example:
11
+
12
+ mycli hello name --from me
13
+
14
+ * command: hello
15
+ * parameters: name
16
+ * option: --from
17
+
18
+ When command parameters are done processing, the remaining completion words will be options. We can tell that the command params are completed based on the method arity.
19
+
20
+ ## Arity
21
+
22
+ For example, say you had a method for a CLI command with the following form:
23
+
24
+ ufo scale service count --cluster development
25
+
26
+ It's equivalent ruby method:
27
+
28
+ scale(service, count) = has an arity of 2
29
+
30
+ So typing:
31
+
32
+ ufo scale service count [TAB] # there are 3 parameters including the "scale" command according to Thor's CLI processing.
33
+
34
+ So the completion should only show options, something like this:
35
+
36
+ --noop --verbose --cluster
37
+
38
+ ## Splat Arguments
39
+
40
+ When the ruby method has a splat argument, it's arity is negative. Here are some example methods and their arities.
41
+
42
+ ship(service) = 1
43
+ scale(service, count) = 2
44
+ ships(*services) = -1
45
+ foo(example, *rest) = -2
46
+
47
+ Fortunately, negative and positive arity values are processed the same way. So we take simply take the absolute value of the arity and process it the same.
48
+
49
+ Here are some test cases, hit TAB after typing the command:
50
+
51
+ forger completion
52
+ forger completion hello
53
+ forger completion hello name
54
+ forger completion hello name --
55
+ forger completion hello name --noop
56
+
57
+ forger completion
58
+ forger completion sub:goodbye
59
+ forger completion sub:goodbye name
60
+
61
+ ## Subcommands and Thor::Group Registered Commands
62
+
63
+ Sometimes the commands are not simple thor commands but are subcommands or Thor::Group commands. A good specific example is the ufo tool.
64
+
65
+ * regular command: ufo ship
66
+ * subcommand: ufo docker
67
+ * Thor::Group command: ufo init
68
+
69
+ Auto-completion accounts for each of these type of commands.
70
+ =end
71
+ module Forger
72
+ class Completer
73
+ autoload :Script, 'forger/completer/script'
74
+
75
+ def initialize(command_class, *params)
76
+ @params = params
77
+ @current_command = @params[0]
78
+ @command_class = command_class # CLI initiall
79
+ end
80
+
81
+ def run
82
+ if subcommand?(@current_command)
83
+ subcommand_class = @command_class.subcommand_classes[@current_command]
84
+ @params.shift # destructive
85
+ Completer.new(subcommand_class, *@params).run # recursively use subcommand
86
+ return
87
+ end
88
+
89
+ # full command has been found!
90
+ unless found?(@current_command)
91
+ puts all_commands
92
+ return
93
+ end
94
+
95
+ # will only get to here if command aws found (above)
96
+ arity = @command_class.instance_method(@current_command).arity.abs
97
+ if @params.size > arity or thor_group_command?
98
+ puts options_completion
99
+ else
100
+ puts params_completion
101
+ end
102
+ end
103
+
104
+ def subcommand?(command)
105
+ @command_class.subcommands.include?(command)
106
+ end
107
+
108
+ # hacky way to detect that command is a registered Thor::Group command
109
+ def thor_group_command?
110
+ command_params(raw=true) == [[:rest, :args]]
111
+ end
112
+
113
+ def found?(command)
114
+ public_methods = @command_class.public_instance_methods(false)
115
+ command && public_methods.include?(command.to_sym)
116
+ end
117
+
118
+ # all top-level commands
119
+ def all_commands
120
+ commands = @command_class.all_commands.reject do |k,v|
121
+ v.is_a?(Thor::HiddenCommand)
122
+ end
123
+ commands.keys
124
+ end
125
+
126
+ def command_params(raw=false)
127
+ params = @command_class.instance_method(@current_command).parameters
128
+ # Example:
129
+ # >> Sub.instance_method(:goodbye).parameters
130
+ # => [[:req, :name]]
131
+ # >>
132
+ raw ? params : params.map!(&:last)
133
+ end
134
+
135
+ def params_completion
136
+ offset = @params.size - 1
137
+ offset_params = command_params[offset..-1]
138
+ command_params[offset..-1].first
139
+ end
140
+
141
+ def options_completion
142
+ used = ARGV.select { |a| a.include?('--') } # so we can remove used options
143
+
144
+ method_options = @command_class.all_commands[@current_command].options.keys
145
+ class_options = @command_class.class_options.keys
146
+
147
+ all_options = method_options + class_options + ['help']
148
+
149
+ all_options.map! { |o| "--#{o.to_s.gsub('_','-')}" }
150
+ filtered_options = all_options - used
151
+ filtered_options.uniq
152
+ end
153
+
154
+ # Useful for debugging. Using puts messes up completion.
155
+ def log(msg)
156
+ File.open("/tmp/complete.log", "a") do |file|
157
+ file.puts(msg)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,6 @@
1
+ class Forger::Completer::Script
2
+ def self.generate
3
+ bash_script = File.expand_path("script.sh", File.dirname(__FILE__))
4
+ puts "source #{bash_script}"
5
+ end
6
+ end
@@ -0,0 +1,10 @@
1
+ _forger() {
2
+ COMPREPLY=()
3
+ local word="${COMP_WORDS[COMP_CWORD]}"
4
+ local words=("${COMP_WORDS[@]}")
5
+ unset words[0]
6
+ local completion=$(forger completion ${words[@]})
7
+ COMPREPLY=( $(compgen -W "$completion" -- "$word") )
8
+ }
9
+
10
+ complete -F _forger forger
@@ -0,0 +1,20 @@
1
+ require 'yaml'
2
+
3
+ module Forger
4
+ class Config < Base
5
+ def initialize(options={})
6
+ super
7
+ @path = options[:path] || "#{Forger.root}/config/#{Forger.env}.yml"
8
+ end
9
+
10
+ @@data = nil
11
+ def data
12
+ return @@data if @@data
13
+ @@data = YAML.load_file(@path)
14
+ rescue Errno::ENOENT => e
15
+ puts e.message
16
+ puts "The #{@path} does not exist. Please double check that it exists."
17
+ exit
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,51 @@
1
+ require 'pathname'
2
+ require 'yaml'
3
+
4
+ module Forger
5
+ module Core
6
+ @@config = nil
7
+ def config
8
+ @@config ||= Config.new.data
9
+ end
10
+
11
+ def settings
12
+ Setting.new.data
13
+ end
14
+
15
+ def root
16
+ path = ENV['AWS_EC2_ROOT'] || '.'
17
+ Pathname.new(path)
18
+ end
19
+
20
+ def validate_in_project!
21
+ unless File.exist?("#{root}/profiles")
22
+ puts "Could not find a profiles folder in the current directory. It does not look like you are running this command within a forger project. Please confirm that you are in a forger project and try again.".colorize(:red)
23
+ exit
24
+ end
25
+ end
26
+
27
+ @@env = nil
28
+ def env
29
+ return @@env if @@env
30
+ env = env_from_profile(ENV['AWS_PROFILE']) || 'development'
31
+ env = ENV['AWS_EC2_ENV'] if ENV['AWS_EC2_ENV'] # highest precedence
32
+ @@env = env
33
+ end
34
+
35
+ private
36
+ # Do not use the Setting class to load the profile because it can cause an
37
+ # infinite loop then if we decide to use Forger.env from within settings class.
38
+ def env_from_profile(aws_profile)
39
+ settings_path = "#{Forger.root}/config/settings.yml"
40
+ return unless File.exist?(settings_path)
41
+
42
+ data = YAML.load_file(settings_path)
43
+ env = data.find do |_env, setting|
44
+ setting ||= {}
45
+ profiles = setting['aws_profiles']
46
+ profiles && profiles.include?(aws_profile)
47
+ end
48
+ env.first if env
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,155 @@
1
+ require 'yaml'
2
+ require 'active_support/core_ext/hash'
3
+
4
+ module Forger
5
+ class Create < Base
6
+ autoload :Params, "forger/create/params"
7
+ autoload :ErrorMessages, "forger/create/error_messages"
8
+
9
+ include AwsService
10
+ include ErrorMessages
11
+
12
+ def run
13
+ Profile.new(@options).check!
14
+
15
+ Hook.run(:before_run_instances, @options)
16
+ sync_scripts_to_s3
17
+
18
+ puts "Creating EC2 instance #{@name.colorize(:green)}"
19
+ display_ec2_info
20
+ if @options[:noop]
21
+ puts "NOOP mode enabled. EC2 instance not created."
22
+ return
23
+ end
24
+ resp = run_instances(params)
25
+ instance_id = resp.instances.first.instance_id
26
+ display_spot_info(instance_id)
27
+ puts "EC2 instance #{@name} created: #{instance_id} 🎉"
28
+ puts "Visit https://console.aws.amazon.com/ec2/home to check on the status"
29
+ display_cloudwatch_info(instance_id)
30
+ end
31
+
32
+ def run_instances(params)
33
+ ec2.run_instances(params)
34
+ rescue Aws::EC2::Errors::ServiceError => e
35
+ handle_ec2_service_error!(e)
36
+ end
37
+
38
+ # Configured by config/settings.yml.
39
+ # Example: config/settings.yml:
40
+ #
41
+ # development:
42
+ # s3_folder: my-bucket/folder
43
+ def sync_scripts_to_s3
44
+ if Forger.settings["s3_folder"]
45
+ Script::Upload.new(@options).run
46
+ end
47
+ end
48
+
49
+ # params are main derived from profile files
50
+ def params
51
+ @params ||= Params.new(@options).generate
52
+ end
53
+
54
+ def display_spot_info(instance_id)
55
+ resp = ec2.describe_instances(instance_ids: [instance_id])
56
+ spot_id = resp.reservations.first.instances.first.spot_instance_request_id
57
+ return unless spot_id
58
+
59
+ puts "Spot instance request id: #{spot_id}"
60
+ end
61
+
62
+ def display_ec2_info
63
+ puts "Using the following parameters:"
64
+ pretty_display(params)
65
+
66
+ display_launch_template
67
+ end
68
+
69
+ def display_launch_template
70
+ launch_template = params[:launch_template]
71
+ return unless launch_template
72
+
73
+ resp = ec2.describe_launch_template_versions(
74
+ launch_template_id: launch_template[:launch_template_id],
75
+ launch_template_name: launch_template[:launch_template_name],
76
+ )
77
+ versions = resp.launch_template_versions
78
+ launch_template_data = {} # combined launch_template_data
79
+ versions.sort_by { |v| v[:version_number] }.each do |v|
80
+ launch_template_data.merge!(v[:launch_template_data])
81
+ end
82
+ puts "launch template data (versions combined):"
83
+ pretty_display(launch_template_data)
84
+ rescue Aws::EC2::Errors::InvalidLaunchTemplateNameNotFoundException => e
85
+ puts "ERROR: The specified launched template #{launch_template.inspect} was not found."
86
+ puts "Please double check that it exists."
87
+ exit
88
+ end
89
+
90
+ def display_cloudwatch_info(instance_id)
91
+ return unless @options[:cloudwatch]
92
+
93
+ region = get_region
94
+ stream = "#{instance_id}/var/log/cloud-init-output.log"
95
+ url = "https://#{region}.console.aws.amazon.com/cloudwatch/home?region=#{region}#logEventViewer:group=ec2;stream=#{stream}"
96
+ cw_init_log = "cw tail -f ec2 #{stream}"
97
+ puts "To view instance's cloudwatch logs visit:"
98
+ puts " #{url}"
99
+
100
+ puts " #{cw_init_log}" if ENV['AWS_EC2_CW']
101
+ if ENV['AWS_EC2_CW'] && @options[:auto_terminate]
102
+ cw_terminate_log = "cw tail -f ec2 #{instance_id}/var/log/auto-terminate.log"
103
+ puts " #{cw_terminate_log}"
104
+ end
105
+
106
+ puts "Note: It takes a little time for the instance to launch and report logs."
107
+
108
+ paste_command = ENV['AWS_EC2_CW'] ? cw_init_log : url
109
+ add_to_clipboard(paste_command)
110
+ end
111
+
112
+ def add_to_clipboard(text)
113
+ return unless RUBY_PLATFORM =~ /darwin/
114
+ return unless system("type pbcopy > /dev/null")
115
+
116
+ system(%[echo "#{text}" | pbcopy])
117
+ puts "Pro tip: The CloudWatch Console Link has been added to your copy-and-paste clipboard."
118
+ end
119
+
120
+ def get_region
121
+ # Highest precedence: AWS_EC2_REGION env variable. Only really used here.
122
+ if ENV['AWS_EC2_REGION']
123
+ return ENV['AWS_EC2_REGION']
124
+ end
125
+
126
+ # Pretty high in precedence: AWS_PROFILE and ~/.aws/config and
127
+ aws_found = system("type aws > /dev/null")
128
+ if aws_found
129
+ region = `aws configure get region`.strip
130
+ return region
131
+ end
132
+
133
+ # Assumes instance same region as the calling ec2 instance.
134
+ # It is possible for curl not to be installed.
135
+ curl_found = system("type curl > /dev/null")
136
+ if curl_found
137
+ region = `curl --connect-timeout 3 -s 169.254.169.254/latest/meta-data/placement/availability-zone | sed s'/.$//'`
138
+ return region unless region == ''
139
+ end
140
+
141
+ return 'us-east-1' # fallback default
142
+ end
143
+
144
+ def pretty_display(data)
145
+ data = data.deep_stringify_keys
146
+
147
+ if data["user_data"]
148
+ message = "base64-encoded: cat tmp/user-data.txt to view"
149
+ data["user_data"] = message
150
+ end
151
+
152
+ puts YAML.dump(data)
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,58 @@
1
+ require "active_support/core_ext/string"
2
+
3
+ class Forger::Create
4
+ module ErrorMessages
5
+ def handle_ec2_service_error!(exception)
6
+ meth = map_exception_to_method(exception)
7
+ if respond_to?(meth)
8
+ message = send(meth) # custom specific error message
9
+ message = print_error_message(exception, message)
10
+ else
11
+ # generic error message
12
+ print_error_message(exception, <<-EOL)
13
+ There was an error with the parameters used for the run_instance method.
14
+ EOL
15
+ end
16
+ end
17
+
18
+ # Examples:
19
+ # Aws::EC2::Errors::InvalidGroupNotFound => invalid_group_not_found!
20
+ # Aws::EC2::Errors::InvalidParameterCombination => invalid_parameter_combination!
21
+ def map_exception_to_method(exception)
22
+ class_name = File.basename(exception.class.to_s).sub(/.*::/,'')
23
+ class_name.underscore # method_name
24
+ end
25
+
26
+ def print_error_message(exception, message)
27
+ puts "ERROR: Unable to launch the instance.".colorize(:red)
28
+ puts message
29
+ puts exception.message
30
+ puts "For the full internal backtrace re-run the command with DEBUG=1"
31
+ puts exception.backtrace if ENV['DEBUG']
32
+ exit 1
33
+ end
34
+
35
+ #######################################################
36
+ # specific messages with a little more info for more common error cases below:
37
+ def invalid_group_not_found
38
+ <<-EOL
39
+ The security group passed in does not exit.
40
+ Please double check that security group exists in the VPC.
41
+ EOL
42
+ end
43
+
44
+ def invalid_parameter_combination
45
+ <<-EOL
46
+ The parameters passed to the run_instances method were invalid.
47
+ Please double check that the parameters are all valid.
48
+ EOL
49
+ end
50
+
51
+ def invalid_subnet_id_not_found
52
+ <<-EOL
53
+ The provided subnets ids were were not found.
54
+ Please double check that the subnets exists.
55
+ EOL
56
+ end
57
+ end
58
+ end