putpaws 0.0.9 → 0.1.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +153 -2
  3. data/lib/Putpawsfile +4 -0
  4. data/lib/putpaws/ai/ai_task.rb +1 -0
  5. data/lib/putpaws/ai/guide.rb +119 -0
  6. data/lib/putpaws/ai/tasks/ai_task.rake +14 -0
  7. data/lib/putpaws/application_config.rb +15 -2
  8. data/lib/putpaws/ecs/run_command.rb +115 -0
  9. data/lib/putpaws/ecs/task_command.rb +63 -9
  10. data/lib/putpaws/ecs/tasks/ecs_task.rake +113 -6
  11. data/lib/putpaws/iam/grant_command.rb +205 -0
  12. data/lib/putpaws/iam/iam_task.rb +1 -0
  13. data/lib/putpaws/iam/operator_config.rb +46 -0
  14. data/lib/putpaws/iam/tasks/iam_task.rake +65 -0
  15. data/lib/putpaws/info.rb +1 -0
  16. data/lib/putpaws/provision/all.rb +29 -0
  17. data/lib/putpaws/provision/aws_clients.rb +59 -0
  18. data/lib/putpaws/provision/config_writer.rb +88 -0
  19. data/lib/putpaws/provision/policy_generator.rb +221 -0
  20. data/lib/putpaws/provision/preset.rb +91 -0
  21. data/lib/putpaws/provision/presets/rails-nginx/preset.json +32 -0
  22. data/lib/putpaws/provision/presets/rails-nginx/templates/taskdef-app.json.erb +29 -0
  23. data/lib/putpaws/provision/presets/rails-nginx/templates/taskdef-web.json.erb +48 -0
  24. data/lib/putpaws/provision/provision_config.rb +156 -0
  25. data/lib/putpaws/provision/provision_task.rb +1 -0
  26. data/lib/putpaws/provision/resources/base.rb +49 -0
  27. data/lib/putpaws/provision/resources/cluster.rb +30 -0
  28. data/lib/putpaws/provision/resources/codebuild_project.rb +85 -0
  29. data/lib/putpaws/provision/resources/iam_role.rb +89 -0
  30. data/lib/putpaws/provision/resources/log_group.rb +50 -0
  31. data/lib/putpaws/provision/resources/security_group.rb +72 -0
  32. data/lib/putpaws/provision/resources/service.rb +71 -0
  33. data/lib/putpaws/provision/resources/task_definition.rb +119 -0
  34. data/lib/putpaws/provision/runner.rb +218 -0
  35. data/lib/putpaws/provision/state.rb +45 -0
  36. data/lib/putpaws/provision/tasks/provision.rake +129 -0
  37. data/lib/putpaws/provision/util.rb +50 -0
  38. data/lib/putpaws/tasks/info.rake +35 -0
  39. data/lib/putpaws/version.rb +1 -1
  40. metadata +62 -2
@@ -1,15 +1,23 @@
1
+ require "shellwords"
1
2
  require "putpaws/prompt"
2
3
  require "putpaws/ecs/task_command"
4
+ require "putpaws/ecs/run_command"
3
5
 
4
6
  namespace :ecs do
5
7
  desc "Set ECS task."
6
8
  task :set_task do
7
9
  aws = Putpaws::Ecs::TaskCommand.config(fetch(:app))
8
- ecs_tasks = aws.list_ecs_tasks.map{|t|
10
+ ecs_tasks = aws.list_ecs_tasks.map{|t|
9
11
  task_id = t.task_arn.split('/').last
10
12
  task_def = t.task_definition_arn.split('/').last
11
- ["#{task_id} (#{task_def}) #{t.last_status}", t]
13
+ label = "#{task_id} (#{task_def}) #{t.last_status}"
14
+ label += " [#{t.group}]" unless t.group.to_s.start_with?('family:')
15
+ [label, t]
12
16
  }.to_h
17
+ if ecs_tasks.empty?
18
+ raise "No running ECS tasks found on cluster: #{aws.cluster}" +
19
+ (aws.task_name_prefix ? " (task_name_prefix: #{aws.task_name_prefix})" : "")
20
+ end
13
21
  prompt = Putpaws::Prompt.safe
14
22
  selected = prompt.select("Choose a task you're going to operate", ecs_tasks.keys)
15
23
  ecs_task = ecs_tasks[selected]
@@ -23,8 +31,8 @@ namespace :ecs do
23
31
  aws = Putpaws::Ecs::TaskCommand.config(fetch(:app))
24
32
  aws.ecs_task = ecs_task
25
33
  cmd = aws.get_attach_command(container: ENV['container'])
26
- puts cmd
27
- system(cmd)
34
+ puts Shellwords.join(cmd)
35
+ system(*cmd)
28
36
  end
29
37
 
30
38
  desc "Run port forwarding session. You need to enable ECS Exec on a specified task and also install session-manager-plugin."
@@ -44,7 +52,106 @@ namespace :ecs do
44
52
  remote_port: remote_port,
45
53
  local_port: local_port
46
54
  )
47
- puts cmd
48
- system(cmd)
55
+ puts Shellwords.join(cmd)
56
+ system(*cmd)
57
+ end
58
+
59
+ desc "Run a command on a temporary ECS task. The task terminates itself when the command finishes. (Ex) cmd='bundle exec rake db:migrate' wait=true"
60
+ task :run do
61
+ runner = Putpaws::Ecs::RunCommand.config(fetch(:app))
62
+ command = ENV['cmd']
63
+ if command.nil? || command.strip.empty?
64
+ raise "Please specify a command like: cmd='bundle exec rake db:migrate'"
65
+ end
66
+
67
+ ecs_task = runner.run_ecs_task(
68
+ command: command,
69
+ started_by: 'putpaws-run',
70
+ group: 'putpaws-run',
71
+ container: ENV['container']
72
+ )
73
+ task_id = ecs_task.task_arn.split('/').last
74
+ puts "Task started: #{task_id} [putpaws-run]"
75
+
76
+ if ENV['wait']
77
+ puts "Waiting for the task to stop..."
78
+ ecs_task = runner.wait_until_stopped(ecs_task.task_arn)
79
+ ctn_name = ENV['container'] || runner.container_name
80
+ ctn = ecs_task.containers.detect{|c| c.name == ctn_name}
81
+ exit_code = ctn && ctn.exit_code
82
+ puts "Task stopped: #{ecs_task.stopped_reason}"
83
+ raise "Command failed with exit code: #{exit_code.inspect}" unless exit_code == 0
84
+ puts "Command succeeded."
85
+ else
86
+ puts "The task terminates itself when the command finishes."
87
+ end
88
+ end
89
+
90
+ desc "Update ECS service with force new deployment (redeploy). (Ex) desired=2 taskdef=my-family wait=true"
91
+ task :deploy do
92
+ aws = Putpaws::Ecs::TaskCommand.config(fetch(:app))
93
+ service_name = aws.service
94
+ if service_name.nil?
95
+ services = aws.list_ecs_services
96
+ raise "No ECS services found on cluster: #{aws.cluster}" if services.empty?
97
+ service_name = if services.one?
98
+ services.first.service_name
99
+ else
100
+ prompt = Putpaws::Prompt.safe
101
+ prompt.select("Choose a service to update", services.map(&:service_name))
102
+ end
103
+ end
104
+
105
+ svc = aws.update_ecs_service(
106
+ service: service_name,
107
+ desired_count: ENV['desired'],
108
+ task_definition: ENV['taskdef']
109
+ )
110
+ task_def = svc.task_definition.to_s.split('/').last
111
+ puts "Deployment started: service=#{svc.service_name} task_definition=#{task_def} desired_count=#{svc.desired_count}"
112
+
113
+ if ENV['wait']
114
+ puts "Waiting for the service to be stable..."
115
+ aws.wait_ecs_service_stable(service: service_name)
116
+ puts "Service is stable."
117
+ else
118
+ puts "Check progress with: bundle exec putpaws #{fetch(:app).name} log:tailf"
119
+ end
120
+ end
121
+
122
+ desc "Launch a temporary ECS task for operation and attach to it. The task terminates itself when ttl passes (default 30m). (Ex) ttl=45m keep=true"
123
+ task :shell do
124
+ runner = Putpaws::Ecs::RunCommand.config(fetch(:app))
125
+ ttl = Putpaws::Ecs::RunCommand.parse_ttl(ENV['ttl'])
126
+
127
+ puts "Launching a temporary task which terminates itself after #{ttl} seconds"
128
+ ecs_task = runner.run_ecs_task(
129
+ command: "sleep #{ttl}",
130
+ started_by: 'putpaws-shell',
131
+ group: 'putpaws-shell'
132
+ )
133
+ task_id = ecs_task.task_arn.split('/').last
134
+ puts "Task started: #{task_id} [putpaws-shell]"
135
+
136
+ begin
137
+ puts "Waiting for the task to be attachable..."
138
+ ecs_task = runner.wait_until_attachable(ecs_task.task_arn)
139
+
140
+ aws = Putpaws::Ecs::TaskCommand.new(
141
+ region: runner.region,
142
+ cluster: runner.cluster_name
143
+ )
144
+ aws.ecs_task = ecs_task
145
+ cmd = aws.get_attach_command(container: ENV['container'] || runner.container_name)
146
+ puts Shellwords.join(cmd)
147
+ system(*cmd)
148
+ ensure
149
+ if ENV['keep']
150
+ puts "Task #{task_id} keeps running and terminates itself within #{ttl} seconds."
151
+ else
152
+ runner.stop_ecs_task(ecs_task.task_arn, reason: 'Stopped by putpaws ecs:shell')
153
+ puts "Task #{task_id} stopped."
154
+ end
155
+ end
49
156
  end
50
157
  end
@@ -0,0 +1,205 @@
1
+ require 'json'
2
+ require 'uri'
3
+ require 'putpaws/iam/operator_config'
4
+
5
+ module Putpaws
6
+ module Iam
7
+ # Resolves operator managed policies idempotently.
8
+ # Policy names are derived from the profile name
9
+ # ({service}-operator-{profile}), so re-running converges to
10
+ # CREATE / UPDATE (new policy version) / SKIP instead of piling up policies.
11
+ # The group -> IAM action mapping lives here in code: when putpaws commands
12
+ # evolve, the next `iam:grant` surfaces the permission diff as an UPDATE.
13
+ class GrantCommand
14
+ GROUPS = %w[attach shell deploy logs codebuild scheduler]
15
+
16
+ attr_reader :app
17
+ def initialize(app:, account_id: nil, iam_client: nil, sts_client: nil)
18
+ @app = app
19
+ @account_id = account_id
20
+ @iam_client = iam_client
21
+ @sts_client = sts_client
22
+ end
23
+
24
+ def iam_client
25
+ @iam_client ||= begin
26
+ require 'aws-sdk-iam'
27
+ Aws::IAM::Client.new(region: app.region)
28
+ end
29
+ end
30
+
31
+ def sts_client
32
+ @sts_client ||= Aws::STS::Client.new(region: app.region)
33
+ end
34
+
35
+ def account_id
36
+ @account_id ||= sts_client.get_caller_identity.account
37
+ end
38
+
39
+ def region
40
+ app.region
41
+ end
42
+
43
+ def cluster_arn
44
+ "arn:aws:ecs:#{region}:#{account_id}:cluster/#{app.cluster}"
45
+ end
46
+
47
+ def policy_name(profile)
48
+ "#{app.name}-operator-#{profile.name}"
49
+ end
50
+
51
+ def policy_arn(profile)
52
+ "arn:aws:iam::#{account_id}:policy/#{policy_name(profile)}"
53
+ end
54
+
55
+ def build_policy_document(profile)
56
+ statements = profile.groups.flat_map{|g| statements_for(g.to_s)}
57
+ statements += (profile.extra_statements || [])
58
+ {Version: '2012-10-17', Statement: statements}
59
+ end
60
+
61
+ def current_document(profile)
62
+ policy = iam_client.get_policy(policy_arn: policy_arn(profile)).policy
63
+ ver = iam_client.get_policy_version(
64
+ policy_arn: policy_arn(profile),
65
+ version_id: policy.default_version_id
66
+ )
67
+ JSON.parse(URI.decode_www_form_component(ver.policy_version.document))
68
+ rescue Aws::IAM::Errors::NoSuchEntityException
69
+ nil
70
+ end
71
+
72
+ def plan(profile)
73
+ current = current_document(profile)
74
+ return :create if current.nil?
75
+ desired = JSON.parse(JSON.generate(build_policy_document(profile)))
76
+ current == desired ? :skip : :update
77
+ end
78
+
79
+ def apply!(profile)
80
+ doc = JSON.generate(build_policy_document(profile))
81
+ if current_document(profile).nil?
82
+ iam_client.create_policy(
83
+ policy_name: policy_name(profile),
84
+ policy_document: doc,
85
+ description: "putpaws operator policy for #{app.name} (profile: #{profile.name})"
86
+ )
87
+ else
88
+ prune_versions!(profile)
89
+ iam_client.create_policy_version(
90
+ policy_arn: policy_arn(profile),
91
+ policy_document: doc,
92
+ set_as_default: true
93
+ )
94
+ end
95
+ policy_arn(profile)
96
+ end
97
+
98
+ # Managed policies keep at most 5 versions.
99
+ def prune_versions!(profile)
100
+ versions = iam_client.list_policy_versions(policy_arn: policy_arn(profile)).versions
101
+ return if versions.size < 5
102
+ oldest = versions.reject(&:is_default_version).min_by(&:create_date)
103
+ return unless oldest
104
+ iam_client.delete_policy_version(policy_arn: policy_arn(profile), version_id: oldest.version_id)
105
+ end
106
+
107
+ def statements_for(group)
108
+ case group
109
+ when 'attach'
110
+ [
111
+ {Sid: 'AttachListTasks', Effect: 'Allow',
112
+ Action: %w[ecs:ListTasks ecs:DescribeTasks],
113
+ Resource: '*',
114
+ Condition: {ArnEquals: {'ecs:cluster' => cluster_arn}}},
115
+ {Sid: 'AttachExec', Effect: 'Allow',
116
+ Action: %w[ecs:ExecuteCommand],
117
+ Resource: "arn:aws:ecs:#{region}:#{account_id}:task/#{app.cluster}/*"},
118
+ {Sid: 'AttachPortForward', Effect: 'Allow',
119
+ Action: %w[ssm:StartSession],
120
+ Resource: [
121
+ "arn:aws:ssm:#{region}::document/AWS-StartPortForwardingSessionToRemoteHost",
122
+ "arn:aws:ecs:#{region}:#{account_id}:task/#{app.cluster}/*",
123
+ ]},
124
+ ]
125
+ when 'shell'
126
+ prefix = app.task_name_prefix || app.name
127
+ [
128
+ {Sid: 'ShellRunTask', Effect: 'Allow',
129
+ Action: %w[ecs:RunTask ecs:StopTask],
130
+ Resource: [
131
+ "arn:aws:ecs:#{region}:#{account_id}:task-definition/#{prefix}*",
132
+ "arn:aws:ecs:#{region}:#{account_id}:task-definition/#{prefix}*:*",
133
+ "arn:aws:ecs:#{region}:#{account_id}:task/#{app.cluster}/*",
134
+ ]},
135
+ {Sid: 'ShellDescribeTasks', Effect: 'Allow',
136
+ Action: %w[ecs:DescribeTasks],
137
+ Resource: '*',
138
+ Condition: {ArnEquals: {'ecs:cluster' => cluster_arn}}},
139
+ {Sid: 'ShellExec', Effect: 'Allow',
140
+ Action: %w[ecs:ExecuteCommand],
141
+ Resource: "arn:aws:ecs:#{region}:#{account_id}:task/#{app.cluster}/*"},
142
+ {Sid: 'ShellPassRole', Effect: 'Allow',
143
+ Action: %w[iam:PassRole],
144
+ Resource: "arn:aws:iam::#{account_id}:role/#{app.name}-*"},
145
+ ]
146
+ when 'deploy'
147
+ [
148
+ {Sid: 'DeployListServices', Effect: 'Allow',
149
+ Action: %w[ecs:ListServices],
150
+ Resource: '*',
151
+ Condition: {ArnEquals: {'ecs:cluster' => cluster_arn}}},
152
+ {Sid: 'DeployUpdateService', Effect: 'Allow',
153
+ Action: %w[ecs:UpdateService ecs:DescribeServices],
154
+ Resource: "arn:aws:ecs:#{region}:#{account_id}:service/#{app.cluster}/#{app.service || '*'}"},
155
+ ]
156
+ when 'logs'
157
+ r = app.log_region || region
158
+ [
159
+ {Sid: 'LogsDescribeGroups', Effect: 'Allow',
160
+ Action: %w[logs:DescribeLogGroups],
161
+ Resource: "arn:aws:logs:#{r}:#{account_id}:log-group:*"},
162
+ {Sid: 'LogsRead', Effect: 'Allow',
163
+ Action: %w[logs:DescribeLogStreams logs:FilterLogEvents logs:GetLogEvents],
164
+ Resource: [
165
+ "arn:aws:logs:#{r}:#{account_id}:log-group:#{app.log_group_prefix}*",
166
+ "arn:aws:logs:#{r}:#{account_id}:log-group:#{app.log_group_prefix}*:*",
167
+ ]},
168
+ ]
169
+ when 'codebuild'
170
+ r = app.build_region || region
171
+ [
172
+ {Sid: 'BuildList', Effect: 'Allow',
173
+ Action: %w[codebuild:ListProjects],
174
+ Resource: '*'},
175
+ {Sid: 'BuildStart', Effect: 'Allow',
176
+ Action: %w[codebuild:StartBuild codebuild:StopBuild codebuild:BatchGetBuilds codebuild:BatchGetProjects],
177
+ Resource: "arn:aws:codebuild:#{r}:#{account_id}:project/#{app.build_project_name_prefix}*"},
178
+ {Sid: 'BuildLogsRead', Effect: 'Allow',
179
+ Action: %w[logs:DescribeLogStreams logs:FilterLogEvents logs:GetLogEvents],
180
+ Resource: [
181
+ "arn:aws:logs:#{r}:#{account_id}:log-group:#{app.build_log_group_prefix}*",
182
+ "arn:aws:logs:#{r}:#{account_id}:log-group:#{app.build_log_group_prefix}*:*",
183
+ ]},
184
+ ]
185
+ when 'scheduler'
186
+ scheduler_role = (app.target && app.target.scheduler_role) ||
187
+ "arn:aws:iam::#{account_id}:role/#{app.name}-*"
188
+ [
189
+ {Sid: 'ScheduleList', Effect: 'Allow',
190
+ Action: %w[scheduler:ListSchedules],
191
+ Resource: '*'},
192
+ {Sid: 'ScheduleManage', Effect: 'Allow',
193
+ Action: %w[scheduler:GetSchedule scheduler:CreateSchedule scheduler:UpdateSchedule],
194
+ Resource: "arn:aws:scheduler:#{region}:#{account_id}:schedule/default/*"},
195
+ {Sid: 'SchedulePassRole', Effect: 'Allow',
196
+ Action: %w[iam:PassRole],
197
+ Resource: scheduler_role},
198
+ ]
199
+ else
200
+ raise "Unknown group: #{group} (available: #{GROUPS.join(', ')})"
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1 @@
1
+ load File.expand_path("../tasks/iam_task.rake", __FILE__)
@@ -0,0 +1,46 @@
1
+ require 'json'
2
+ require 'pathname'
3
+
4
+ module Putpaws
5
+ module Iam
6
+ # Named operator profiles declared in .putpaws/operators.json.
7
+ # A profile gives an organization-level name (Ex: developer, viewer)
8
+ # to a combination of putpaws command groups.
9
+ class OperatorConfig < Struct.new(:name, :groups, :extra_statements, keyword_init: true)
10
+ EXAMPLE = {
11
+ developer: {groups: %w[attach shell deploy logs codebuild]},
12
+ viewer: {groups: %w[logs]},
13
+ }
14
+
15
+ def self.path(path_prefix: '.putpaws')
16
+ Pathname.new(path_prefix).join('operators.json')
17
+ end
18
+
19
+ def self.exist?(path_prefix: '.putpaws')
20
+ path(path_prefix: path_prefix).exist?
21
+ end
22
+
23
+ def self.scaffold!(path_prefix: '.putpaws')
24
+ p = path(path_prefix: path_prefix)
25
+ File.write(p, JSON.pretty_generate(EXAMPLE) + "\n")
26
+ p.to_s
27
+ end
28
+
29
+ def self.load(path_prefix: '.putpaws')
30
+ p = path(path_prefix: path_prefix)
31
+ return {} unless p.exist?
32
+ JSON.parse(File.read(p), symbolize_names: true)
33
+ end
34
+
35
+ def self.all(path_prefix: '.putpaws')
36
+ load(path_prefix: path_prefix).map{|k, v|
37
+ new(name: k.to_s, groups: v[:groups] || [], extra_statements: v[:extra_statements] || [])
38
+ }
39
+ end
40
+
41
+ def self.find(name, path_prefix: '.putpaws')
42
+ all(path_prefix: path_prefix).detect{|x| x.name == name.to_s}
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,65 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "putpaws/prompt"
4
+ require "putpaws/iam/operator_config"
5
+ require "putpaws/iam/grant_command"
6
+
7
+ namespace :iam do
8
+ desc "Resolve operator managed policies for this service from .putpaws/operators.json profiles. Idempotent. (Ex) profile=developer all=true"
9
+ task :grant do
10
+ app = fetch(:app)
11
+
12
+ unless Putpaws::Iam::OperatorConfig.exist?
13
+ path = Putpaws::Iam::OperatorConfig.scaffold!
14
+ puts "Generated #{path} with example profiles."
15
+ puts "Review and edit it, then run `putpaws #{app.name} iam:grant` again."
16
+ next
17
+ end
18
+
19
+ profiles = Putpaws::Iam::OperatorConfig.all
20
+ raise "No profiles defined in #{Putpaws::Iam::OperatorConfig.path}" if profiles.empty?
21
+
22
+ selected = if ENV['all']
23
+ profiles
24
+ elsif ENV['profile']
25
+ p = Putpaws::Iam::OperatorConfig.find(ENV['profile'])
26
+ raise "Profile not found: #{ENV['profile']} (available: #{profiles.map(&:name).join(', ')})" unless p
27
+ [p]
28
+ elsif profiles.one?
29
+ profiles
30
+ else
31
+ prompt = Putpaws::Prompt.safe
32
+ name = prompt.select("Choose a profile to grant", profiles.map(&:name))
33
+ [profiles.detect{|x| x.name == name}]
34
+ end
35
+
36
+ prompt = Putpaws::Prompt.safe
37
+ cmd = Putpaws::Iam::GrantCommand.new(app: app)
38
+ puts "Service: #{app.name} / Account: #{cmd.account_id} / Region: #{app.region}"
39
+
40
+ selected.each do |profile|
41
+ policy_name = cmd.policy_name(profile)
42
+ action = cmd.plan(profile)
43
+ if action == :skip
44
+ puts "SKIP #{policy_name}"
45
+ next
46
+ end
47
+
48
+ doc = cmd.build_policy_document(profile)
49
+ puts ""
50
+ puts "=== #{action.to_s.upcase} managed policy: #{policy_name} ==="
51
+ puts JSON.pretty_generate(doc)
52
+ unless prompt.yes?("Apply this content as policy #{policy_name}?")
53
+ puts "Skipped #{policy_name}"
54
+ next
55
+ end
56
+
57
+ arn = cmd.apply!(profile)
58
+ copy_dir = File.join('.putpaws', 'provisioning', app.name, 'policies')
59
+ FileUtils.mkdir_p(copy_dir)
60
+ File.write(File.join(copy_dir, "operator-#{profile.name}.json"), JSON.pretty_generate(doc) + "\n")
61
+ puts "Applied: #{arn}"
62
+ puts "Attach example: aws iam attach-user-policy --user-name <USER> --policy-arn #{arn}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1 @@
1
+ load File.expand_path("../tasks/info.rake", __FILE__)
@@ -0,0 +1,29 @@
1
+ require 'putpaws/provision/util'
2
+ require 'putpaws/provision/preset'
3
+ require 'putpaws/provision/provision_config'
4
+ require 'putpaws/provision/state'
5
+ require 'putpaws/provision/aws_clients'
6
+ require 'putpaws/provision/policy_generator'
7
+ require 'putpaws/provision/config_writer'
8
+ require 'putpaws/provision/runner'
9
+
10
+ module Putpaws
11
+ module Provision
12
+ # Resolve which service to operate on: ENV['service'], the only one,
13
+ # or an interactive selection.
14
+ def self.resolve_config(path_prefix: '.putpaws')
15
+ name = ENV['service']
16
+ if Util.blank?(name)
17
+ services = ProvisionConfig.services(path_prefix: path_prefix)
18
+ raise "No provisioned service found. Please run `putpaws ready` first." if services.empty?
19
+ name = if services.one?
20
+ services.first
21
+ else
22
+ require 'putpaws/prompt'
23
+ Putpaws::Prompt.safe.select("Choose a service", services)
24
+ end
25
+ end
26
+ ProvisionConfig.load(name, path_prefix: path_prefix)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,59 @@
1
+ module Putpaws
2
+ module Provision
3
+ # Lazy client factory. Clients can be injected for testing.
4
+ class AwsClients
5
+ attr_reader :region
6
+ def initialize(region:, ec2: nil, ecs: nil, logs: nil, codebuild: nil, ssm: nil, iam: nil)
7
+ @region = region
8
+ @ec2 = ec2
9
+ @ecs = ecs
10
+ @logs = logs
11
+ @codebuild = codebuild
12
+ @ssm = ssm
13
+ @iam = iam
14
+ end
15
+
16
+ def iam
17
+ @iam ||= begin
18
+ require 'aws-sdk-iam'
19
+ Aws::IAM::Client.new(region: region)
20
+ end
21
+ end
22
+
23
+ def ec2
24
+ @ec2 ||= begin
25
+ require 'aws-sdk-ec2'
26
+ Aws::EC2::Client.new(region: region)
27
+ end
28
+ end
29
+
30
+ def ecs
31
+ @ecs ||= begin
32
+ require 'aws-sdk-ecs'
33
+ Aws::ECS::Client.new(region: region)
34
+ end
35
+ end
36
+
37
+ def logs
38
+ @logs ||= begin
39
+ require 'aws-sdk-cloudwatchlogs'
40
+ Aws::CloudWatchLogs::Client.new(region: region)
41
+ end
42
+ end
43
+
44
+ def codebuild
45
+ @codebuild ||= begin
46
+ require 'aws-sdk-codebuild'
47
+ Aws::CodeBuild::Client.new(region: region)
48
+ end
49
+ end
50
+
51
+ def ssm
52
+ @ssm ||= begin
53
+ require 'aws-sdk-ssm'
54
+ Aws::SSM::Client.new(region: region)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,88 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+ require 'pathname'
4
+ require 'putpaws/provision/util'
5
+
6
+ module Putpaws
7
+ module Provision
8
+ # Reflects provisioned resources into .putpaws/application.json and
9
+ # .putpaws/infra.json. Only the keys of the target service are touched.
10
+ # Shows a diff and asks for confirmation before writing.
11
+ class ConfigWriter
12
+ attr_reader :config, :state, :prompt, :io
13
+ def initialize(config:, state:, prompt:, io: $stdout)
14
+ @config = config
15
+ @state = state
16
+ @prompt = prompt
17
+ @io = io
18
+ end
19
+
20
+ def application_entry
21
+ {
22
+ region: config.region,
23
+ cluster: config.cluster_name,
24
+ service: config.service_name,
25
+ task_name_prefix: config.service_name,
26
+ log_group_prefix: config.log_group,
27
+ build_log_group_prefix: config.build_log_group,
28
+ build_project_name_prefix: config.build_project_name,
29
+ network: config.service_name,
30
+ target: config.service_name,
31
+ }
32
+ end
33
+
34
+ def network_entry
35
+ {
36
+ subnets: config.base[:subnets],
37
+ security_groups: state.resources[:security_group_ids] || [],
38
+ assign_public_ip: config.settings[:assign_public_ip] || 'DISABLED',
39
+ }
40
+ end
41
+
42
+ def target_entry
43
+ {
44
+ # Used by EventBridge Scheduler (scheduler:deploy).
45
+ scheduler_role: config.roles[:scheduler_role_arn],
46
+ cluster: state.resources[:cluster_arn],
47
+ # Revision-less on purpose: always run the latest ACTIVE revision.
48
+ task_definition: config.service_task_definition_family,
49
+ container_name: config.service_container_name,
50
+ }
51
+ end
52
+
53
+ def apply!
54
+ write_key(path('application.json'), [config.service_name.to_sym], application_entry)
55
+ write_key(path('infra.json'), [:network, config.service_name.to_sym], network_entry)
56
+ write_key(path('infra.json'), [:target, config.service_name.to_sym], target_entry)
57
+ end
58
+
59
+ private
60
+
61
+ def path(basename)
62
+ Pathname.new(config.path_prefix).join(basename)
63
+ end
64
+
65
+ def write_key(file, keys, entry)
66
+ data = file.exist? ? JSON.parse(File.read(file), symbolize_names: true) : {}
67
+ current = keys.reduce(data){|d, k| d.is_a?(Hash) ? d[k] : nil}
68
+ return if current == entry
69
+
70
+ io.puts "=== #{file} : #{keys.join('.')} ==="
71
+ io.puts "--- before"
72
+ io.puts current ? JSON.pretty_generate(current) : "(none)"
73
+ io.puts "+++ after"
74
+ io.puts JSON.pretty_generate(entry)
75
+ unless prompt.yes?("Update #{file}?")
76
+ io.puts "Skipped updating #{file}"
77
+ return
78
+ end
79
+
80
+ FileUtils.cp(file, "#{file}.bak") if file.exist?
81
+ parent = keys[0..-2].reduce(data){|d, k| d[k] ||= {}}
82
+ parent[keys.last] = entry
83
+ Util.write_json(file, data)
84
+ io.puts "Updated #{file}"
85
+ end
86
+ end
87
+ end
88
+ end