stax 0.0.3 → 0.0.4

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.
@@ -14,6 +14,12 @@ module Stax
14
14
  client.describe_table(table_name: name).table
15
15
  end
16
16
 
17
+ def global_table(name)
18
+ client.describe_global_table(global_table_name: name)&.global_table_description
19
+ rescue ::Aws::DynamoDB::Errors::GlobalTableNotFoundException
20
+ nil
21
+ end
22
+
17
23
  def gsi(name)
18
24
  client.describe_table(table_name: name).table.global_secondary_indexes || []
19
25
  end
@@ -71,6 +77,10 @@ module Stax
71
77
  end
72
78
  end
73
79
 
80
+ def put(opt)
81
+ client.put_item(opt)
82
+ end
83
+
74
84
  def list_backups(opt = {})
75
85
  last_arn = nil
76
86
  backups = []
@@ -16,7 +16,18 @@ module Stax
16
16
  end.map(&:instances).flatten
17
17
  end
18
18
 
19
+ ## list AMIs
20
+ def images(opt = {})
21
+ client.describe_images(opt).images.sort_by(&:creation_date)
22
+ end
23
+
24
+ ## tag AMIs
25
+ def create_tags(opt)
26
+ client.create_tags(opt)
27
+ end
28
+
19
29
  end
30
+
20
31
  end
21
32
  end
22
33
  end
@@ -14,10 +14,27 @@ module Stax
14
14
  client.get_authorization_token.authorization_data
15
15
  end
16
16
 
17
+ def repositories(opt = {})
18
+ paginate(:repositories) do |next_token|
19
+ client.describe_repositories(opt.merge(next_token: next_token))
20
+ end
21
+ end
22
+
17
23
  def exists?(repo, tag)
18
24
  !client.batch_get_image(repository_name: repo, image_ids: [{image_tag: tag}]).images.empty?
19
25
  end
20
26
 
27
+ def login(*registry_ids)
28
+ ids = registry_ids.empty? ? nil : Array(registry_ids)
29
+ client.get_authorization_token(registry_ids: ids).authorization_data
30
+ end
31
+
32
+ def images(opt = {})
33
+ paginate(:image_details) do |next_token|
34
+ client.describe_images(opt.merge(next_token: next_token))
35
+ end
36
+ end
37
+
21
38
  end
22
39
 
23
40
  end
@@ -24,18 +24,18 @@ module Stax
24
24
  client.describe_task_definition(task_definition: name).task_definition
25
25
  end
26
26
 
27
- def list_tasks(cluster, status = :RUNNING)
27
+ def list_tasks(opt)
28
28
  paginate(:task_arns) do |token|
29
- client.list_tasks(cluster: cluster, next_token: token, desired_status: status)
29
+ client.list_tasks(opt.merge(next_token: token))
30
30
  end
31
31
  end
32
32
 
33
- def tasks(cluster, status = :RUNNING)
34
- tasks = list_tasks(cluster, status)
33
+ def tasks(opt = {})
34
+ tasks = list_tasks(opt)
35
35
  if tasks.empty?
36
36
  []
37
37
  else
38
- client.describe_tasks(cluster: cluster, tasks: tasks).tasks
38
+ client.describe_tasks(cluster: opt[:cluster], tasks: tasks).tasks
39
39
  end
40
40
  end
41
41
 
@@ -0,0 +1,51 @@
1
+ module Stax
2
+ module Aws
3
+ class Route53 < Sdk
4
+
5
+ class << self
6
+
7
+ def client
8
+ @_client ||= ::Aws::Route53::Client.new
9
+ end
10
+
11
+ ## list all zones
12
+ def zones
13
+ client.list_hosted_zones.hosted_zones
14
+ end
15
+
16
+ ## list limited number of zones, starting at named zone
17
+ def zones_by_name(name, max_items = nil)
18
+ client.list_hosted_zones_by_name(
19
+ dns_name: name,
20
+ max_items: max_items,
21
+ )&.hosted_zones
22
+ end
23
+
24
+ ## get single matching zone, or nil
25
+ def zone_by_name(name)
26
+ zones_by_name(name, 1).find do |zone|
27
+ zone.name == name
28
+ end
29
+ end
30
+
31
+ ## record sets for named zone
32
+ def record_sets(opt = {})
33
+ client.list_resource_record_sets(opt)&.resource_record_sets
34
+ end
35
+
36
+ def record(name, type = :A)
37
+ zone = name.split('.').last(2).join('.') + '.'
38
+ Aws::Route53.record_sets(
39
+ hosted_zone_id: zone_by_name(zone).id,
40
+ start_record_name: name,
41
+ start_record_type: type,
42
+ ).select do |record|
43
+ (record.name == name) && (record.type == type.to_s)
44
+ end
45
+ end
46
+
47
+ end
48
+
49
+ end
50
+ end
51
+ end
@@ -1,3 +1,5 @@
1
+ require 'stax/aws/sts'
2
+
1
3
  module Stax
2
4
  class Base < Thor
3
5
 
@@ -14,6 +16,14 @@ module Stax
14
16
  @_stack_prefix ||= [app_name, branch_name].compact.join('-') + '-'
15
17
  end
16
18
 
19
+ def aws_account_id
20
+ @_aws_account_id ||= Aws::Sts.id.account
21
+ end
22
+
23
+ def aws_region
24
+ @_aws_region ||= ENV['AWS_REGION']
25
+ end
26
+
17
27
  ## find or create a stack object
18
28
  def stack(id)
19
29
  object = Stax.const_get(id.to_s.capitalize)
@@ -104,6 +114,14 @@ module Stax
104
114
  timestamp.nil? ? '-' : Time.at(timestamp.to_i/1000)
105
115
  end
106
116
 
117
+ ## convert a diff in seconds to d h m s
118
+ def human_time_diff(t, n = 5)
119
+ mm, ss = t.divmod(60)
120
+ hh, mm = mm.divmod(60)
121
+ dd, hh = hh.divmod(24)
122
+ {d: dd, h: hh, m: mm, s: ss}.reject{ |_,v| v == 0 }.map{ |k,v| "#{v.round}#{k}" }.first(n).join
123
+ end
124
+
107
125
  ## convert bytes to nearest unit
108
126
  def human_bytes(bytes, precision = 0)
109
127
  return 0.to_s if bytes < 1
@@ -1,18 +1,45 @@
1
1
  require 'cfer'
2
2
 
3
+ ## TODO: remove these hacks once merged and released in upstream cfer
4
+ ## see cfer PRs: #52, #54
5
+ module Cfer::Core::Functions
6
+ def get_azs(region = '')
7
+ {"Fn::GetAZs" => region}
8
+ end
9
+
10
+ def cidr(ip_block, count, size_mask)
11
+ {"Fn::Cidr" => [ip_block, count, size_mask]}
12
+ end
13
+
14
+ def import_value(value)
15
+ {"Fn::ImportValue" => value}
16
+ end
17
+
18
+ def split(*args)
19
+ {"Fn::Split" => [ *args ].flatten }
20
+ end
21
+ end
22
+
23
+ ## see cfer PR: #56
24
+ module Cfer::Core
25
+ class Stack < Cfer::Block
26
+ def output(name, value, options = {})
27
+ opt = options.each_with_object({}) { |(k,v),h| h[k.to_s.capitalize] = v } # capitalize all keys
28
+ export = opt.has_key?('Export') ? {'Name' => opt['Export']} : nil
29
+ self[:Outputs][name] = opt.merge('Value' => value, 'Export' => export).compact
30
+ end
31
+ end
32
+ end
33
+
3
34
  module Stax
4
35
  class Stack < Base
5
36
  class_option :use_previous_value, aliases: '-u', type: :array, default: [], desc: 'params to use previous value'
6
37
 
7
38
  no_commands do
8
39
 
40
+ ## backward-compatibility
9
41
  def cfer_parameters
10
- {}
11
- end
12
-
13
- ## location of cfer template file
14
- def cfer_template
15
- File.join('cf', "#{class_name}.rb")
42
+ cfn_parameters
16
43
  end
17
44
 
18
45
  ## override with S3 bucket for upload of large templates as needed
@@ -29,37 +56,20 @@ module Stax
29
56
  false
30
57
  end
31
58
 
32
- ## create/update the stack
33
- def cfer_converge(args = {})
34
- opts = {
35
- parameters: stringify_keys(cfer_parameters).except(*options[:use_previous_value]),
36
- template: cfer_template,
37
- follow: true,
38
- number: 1,
39
- s3_path: cfer_s3_path,
40
- notification_arns: cfer_notification_arns,
41
- enable_termination_protection: cfer_termination_protection,
42
- }
43
- Cfer.converge!(stack_name, opts.merge(args))
44
- end
45
-
46
- ## generate JSON for stack without sending to cloudformation
47
- def cfer_generate
48
- opts = {parameters: stringify_keys(cfer_parameters)}
49
- Cfer.generate!(cfer_template, opts)
59
+ ## location of template file
60
+ def cfn_template_path
61
+ File.join('cf', "#{class_name}.rb")
50
62
  end
51
63
 
52
- ## generate method does puts, so steal stdout into a string
53
- def cfer_generate_string
54
- capture_stdout do
55
- cfer_generate
56
- end
64
+ def cfer_client
65
+ @_cfer_client ||= Cfer::Cfn::Client.new({})
57
66
  end
58
67
 
59
- def cfer_tail
60
- Cfer.tail!(stack_name, follow: true, number: 1)
61
- rescue ::Aws::CloudFormation::Errors::ValidationError => e
62
- puts e.message
68
+ ## generate JSON for stack without sending to cloudformation
69
+ def cfer_generate
70
+ Cfer::stack_from_file(cfn_template_path, client: cfer_client, parameters: stringify_keys(cfn_parameters)).to_json
71
+ rescue Cfer::Util::FileDoesNotExistError => e
72
+ fail_task(e.message)
63
73
  end
64
74
 
65
75
  ## temporarily grab stdout to a string
@@ -2,8 +2,6 @@ require 'stax/aws/cfn'
2
2
 
3
3
  module Stax
4
4
  class Cli < Base
5
- include Aws
6
-
7
5
  class_option :branch, type: :string, default: Git.branch, desc: 'git branch to use'
8
6
  class_option :app, type: :string, default: File.basename(Git.toplevel), desc: 'application name'
9
7
 
@@ -14,11 +12,11 @@ module Stax
14
12
 
15
13
  desc 'ls', 'list stacks for this branch'
16
14
  def ls
17
- print_table Cfn.stacks.select { |s|
15
+ print_table Aws::Cfn.stacks.select { |s|
18
16
  s.stack_name.start_with?(stack_prefix)
19
17
  }.map { |s|
20
- [s.stack_name, s.creation_time, color(s.stack_status, Cfn::COLORS), s.template_description]
21
- }
18
+ [s.stack_name, s.creation_time, color(s.stack_status, Aws::Cfn::COLORS), s.template_description]
19
+ }.sort
22
20
  end
23
21
 
24
22
  end
@@ -24,6 +24,24 @@ module Stax
24
24
  end
25
25
  end
26
26
 
27
+ desc 'update', 'meta update task'
28
+ def update
29
+ stack_objects.each do |s|
30
+ if s.exists? && y_or_n?("Update #{s.stack_name}?", :yellow)
31
+ s.update
32
+ end
33
+ end
34
+ end
35
+
36
+ desc 'change', 'meta change task'
37
+ def change
38
+ stack_objects.each do |s|
39
+ if s.exists?
40
+ s.change
41
+ end
42
+ end
43
+ end
44
+
27
45
  desc 'delete', 'meta delete task'
28
46
  def delete
29
47
  stack_objects.reverse.each do |s|
@@ -102,7 +102,7 @@ module Stax
102
102
  method_option :min_size, aliases: '-m', type: :numeric, default: nil, desc: 'set minimum capacity'
103
103
  method_option :max_size, aliases: '-M', type: :numeric, default: nil, desc: 'set maximum capacity'
104
104
  def scale
105
- opt = options.slice(:desired_capacity, :min_size, :max_size)
105
+ opt = options.slice(*%w[desired_capacity min_size max_size])
106
106
  fail_task('No change requested') if opt.empty?
107
107
  stack_asgs.each do |a|
108
108
  debug("Scaling to #{opt} for #{a.logical_resource_id} #{a.physical_resource_id}")
@@ -0,0 +1,98 @@
1
+ require 'stax/aws/codebuild'
2
+
3
+ module Stax
4
+ module Codebuild
5
+ def self.included(thor)
6
+ thor.desc(:codebuild, 'Codebuild subcommands')
7
+ thor.subcommand(:codebuild, Cmd::Codebuild)
8
+ end
9
+
10
+ def stack_projects
11
+ @_stack_projects ||= Aws::Cfn.resources_by_type(stack_name, 'AWS::CodeBuild::Project')
12
+ end
13
+
14
+ def stack_project_names
15
+ @_stack_project_names ||= stack_projects.map(&:physical_resource_id)
16
+ end
17
+ end
18
+
19
+ module Cmd
20
+ class Codebuild < SubCommand
21
+ COLORS = {
22
+ SUCCEEDED: :green,
23
+ FAILED: :red,
24
+ FAULT: :red,
25
+ CLIENT_ERROR: :red,
26
+ STOPPED: :red,
27
+ }
28
+
29
+ no_commands do
30
+ def print_phase(p)
31
+ duration = (d = p.duration_in_seconds) ? "#{d}s" : ''
32
+ status = p.phase_status || (p.phase_type == 'COMPLETED' ? '' : 'in progress')
33
+ puts "%-16s %-12s %4s %s" % [p.phase_type, color(status, COLORS), duration, p.end_time]
34
+ end
35
+ end
36
+
37
+ desc 'projects', 'list projects'
38
+ def projects
39
+ print_table Aws::Codebuild.projects(my.stack_project_names).map { |p|
40
+ [p.name, p.source.location, p.environment.image, p.environment.compute_type, p.last_modified]
41
+ }
42
+ end
43
+
44
+ desc 'builds', 'list builds for stack projects'
45
+ method_option :number, aliases: '-n', type: :numeric, default: 10, desc: 'number of builds to list'
46
+ def builds
47
+ my.stack_project_names.each do |project|
48
+ debug("Builds for #{project}")
49
+ ids = Aws::Codebuild.builds_for_project(project, options[:number])
50
+ print_table Aws::Codebuild.builds(ids).map { |b|
51
+ duration = human_time_diff(b.end_time - b.start_time)
52
+ [b.id, b.initiator, color(b.build_status, COLORS), duration, b.end_time]
53
+ }
54
+ end
55
+ end
56
+
57
+ desc 'phases [ID]', 'show build phases for given or most recent build'
58
+ def phases(id = nil)
59
+ id ||= Aws::Codebuild.builds_for_project(my.stack_project_names.first, 1).first
60
+ debug("Phases for build #{id}")
61
+ Aws::Codebuild.builds([id]).first.phases.each(&method(:print_phase))
62
+ end
63
+
64
+ desc 'tail [ID]', 'tail build phases for build'
65
+ def tail(id = nil)
66
+ trap('SIGINT', 'EXIT') # clean exit with ctrl-c
67
+ id ||= Aws::Codebuild.builds_for_project(my.stack_project_names.first, 1).first
68
+ debug("Phases for build #{id}")
69
+ seen = {}
70
+ loop do
71
+ (Aws::Codebuild.builds([id]).first.phases || []).each do |p|
72
+ i = p.phase_type + p.phase_status.to_s
73
+ print_phase(p) unless seen[i]
74
+ seen[i] = true
75
+ end
76
+ break if seen['COMPLETED']
77
+ sleep(3)
78
+ end
79
+ end
80
+
81
+ desc 'start', 'start a build'
82
+ method_option :project, type: :string, default: nil, desc: 'project to build'
83
+ method_option :version, type: :string, default: nil, desc: 'source version to build (sha/branch/tag)'
84
+ def start
85
+ project = options[:project] || my.stack_project_names.first
86
+ version = options[:version] || Git.sha
87
+ debug("Starting build for #{project} #{version}")
88
+ build = Aws::Codebuild.start(
89
+ project_name: project,
90
+ source_version: version,
91
+ )
92
+ puts build.id
93
+ tail build.id
94
+ end
95
+
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,125 @@
1
+ require 'stax/aws/codepipeline'
2
+
3
+ module Stax
4
+ module Codepipeline
5
+ def self.included(thor)
6
+ thor.desc(:codepipeline, 'Codepipeline subcommands')
7
+ thor.subcommand(:codepipeline, Cmd::Codepipeline)
8
+ end
9
+
10
+ def stack_pipelines
11
+ @_stack_pipelines ||= Aws::Cfn.resources_by_type(stack_name, 'AWS::CodePipeline::Pipeline')
12
+ end
13
+
14
+ def stack_pipeline_names
15
+ @_stack_pipeline_names ||= stack_pipelines.map(&:physical_resource_id)
16
+ end
17
+ end
18
+
19
+ module Cmd
20
+ class Codepipeline < SubCommand
21
+ COLORS = {
22
+ Succeeded: :green,
23
+ Failed: :red,
24
+ }
25
+
26
+ desc 'stages', 'list pipeline stages'
27
+ def stages
28
+ my.stack_pipeline_names.each do |name|
29
+ debug("Stages for #{name}")
30
+ print_table Aws::Codepipeline.stages(name).map { |s|
31
+ actions = s.actions.map{ |a| a&.action_type_id&.provider }.join(' ')
32
+ [s.name, actions]
33
+ }
34
+ end
35
+ end
36
+
37
+ desc 'history', 'pipeline execution history'
38
+ method_option :number, aliases: '-n', type: :numeric, default: 10, desc: 'number of items'
39
+ def history
40
+ my.stack_pipeline_names.each do |name|
41
+ debug("Execution history for #{name}")
42
+ print_table Aws::Codepipeline.executions(name, options[:number]).map { |e|
43
+ r = Aws::Codepipeline.execution(name, e.pipeline_execution_id)&.artifact_revisions&.first
44
+ age = human_time_diff(Time.now - e.last_update_time, 1)
45
+ duration = human_time_diff(e.last_update_time - e.start_time)
46
+ [e.pipeline_execution_id, color(e.status, COLORS), "#{age} ago", duration, r&.revision_id&.slice(0,7) + ':' + r&.revision_summary]
47
+ }
48
+ end
49
+ end
50
+
51
+ desc 'state', 'pipeline state'
52
+ def state
53
+ my.stack_pipeline_names.each do |name|
54
+ state = Aws::Codepipeline.state(name)
55
+ debug("State for #{name} at #{state.updated}")
56
+ print_table state.stage_states.map { |s|
57
+ s.action_states.map { |a|
58
+ l = a.latest_execution
59
+ percent = (l&.percent_complete || 100).to_s + '%'
60
+ sha = a.current_revision&.revision_id&.slice(0,7)
61
+ ago = (t = l&.last_status_change) ? human_time_diff(Time.now - t, 1) : '?'
62
+ [s.stage_name, a.action_name, color(l&.status || '', COLORS), percent, "#{ago} ago", (sha || l&.token), l&.error_details&.message]
63
+ }
64
+ }.flatten(1)
65
+ end
66
+ end
67
+
68
+ desc 'approvals', 'approve or reject pending approvals'
69
+ method_option :approved, type: :boolean, default: false, desc: 'approve the request'
70
+ method_option :rejected, type: :boolean, default: false, desc: 'reject the request'
71
+ def approvals
72
+ my.stack_pipeline_names.each do |name|
73
+ debug("Pending approvals for #{name}")
74
+ Aws::Codepipeline.state(name).stage_states.each do |s|
75
+ s.action_states.each do |a|
76
+ next unless (a.latest_execution&.token && a.latest_execution&.status == 'InProgress')
77
+ l = a.latest_execution
78
+ ago = (t = l&.last_status_change) ? human_time_diff(Time.now - t, 1) : '?'
79
+ puts "#{a.action_name} #{l&.token} #{ago} ago"
80
+ resp = (options[:approved] && :approved) || (options[:rejected] && :rejected) || ask('approved,rejected,[skip]?', :yellow)
81
+ status = resp.to_s.capitalize
82
+ if (status == 'Rejected') || (status == 'Approved')
83
+ Aws::Codepipeline.client.put_approval_result(
84
+ pipeline_name: name,
85
+ stage_name: s.stage_name,
86
+ action_name: a.action_name,
87
+ token: l.token,
88
+ result: {status: status, summary: "#{status} by #{ENV['USER']}"},
89
+ ).tap { |r| puts "#{status} at #{r&.approved_at}" }
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ desc 'start [NAME]', 'start execution for pipeline'
97
+ def start(name = nil)
98
+ name ||= my.stack_pipeline_names.first
99
+ debug("Starting execution for #{name}")
100
+ puts Aws::Codepipeline.start(name)
101
+ tail name
102
+ end
103
+
104
+ desc 'tail [NAME]', 'tail pipeline state changes'
105
+ def tail(name = nil)
106
+ trap('SIGINT', 'EXIT') # clean exit with ctrl-c
107
+ name ||= my.stack_pipeline_names.first
108
+ last_seen = nil
109
+ loop do
110
+ state = Aws::Codepipeline.state(name)
111
+ now = Time.now
112
+ stages = state.stage_states.map do |s|
113
+ last_change = s.action_states.map { |a| a&.latest_execution&.last_status_change }.compact.max
114
+ revisions = s.action_states.map { |a| a.current_revision&.revision_id&.slice(0,7) }.join(' ')
115
+ ago = last_change ? human_time_diff(now - last_change, 1) : '?'
116
+ [s.stage_name, color(s&.latest_execution&.status || '', COLORS), "#{ago} ago", revisions].join(' ')
117
+ end
118
+ puts [set_color(now, :blue), stages].flatten.join(' ')
119
+ sleep 5
120
+ end
121
+ end
122
+
123
+ end
124
+ end
125
+ end