putpaws 0.0.8 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +230 -0
  3. data/lib/Putpawsfile +6 -1
  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 +23 -3
  8. data/lib/putpaws/code_build/build_task.rb +1 -0
  9. data/lib/putpaws/code_build/project_command.rb +57 -0
  10. data/lib/putpaws/code_build/tasks/build_task.rake +39 -0
  11. data/lib/putpaws/ecs/run_command.rb +115 -0
  12. data/lib/putpaws/ecs/task_command.rb +63 -9
  13. data/lib/putpaws/ecs/tasks/ecs_task.rake +113 -6
  14. data/lib/putpaws/iam/grant_command.rb +205 -0
  15. data/lib/putpaws/iam/iam_task.rb +1 -0
  16. data/lib/putpaws/iam/operator_config.rb +46 -0
  17. data/lib/putpaws/iam/tasks/iam_task.rake +65 -0
  18. data/lib/putpaws/info.rb +1 -0
  19. data/lib/putpaws/provision/all.rb +29 -0
  20. data/lib/putpaws/provision/aws_clients.rb +59 -0
  21. data/lib/putpaws/provision/config_writer.rb +88 -0
  22. data/lib/putpaws/provision/policy_generator.rb +221 -0
  23. data/lib/putpaws/provision/preset.rb +91 -0
  24. data/lib/putpaws/provision/presets/rails-nginx/preset.json +32 -0
  25. data/lib/putpaws/provision/presets/rails-nginx/templates/taskdef-app.json.erb +29 -0
  26. data/lib/putpaws/provision/presets/rails-nginx/templates/taskdef-web.json.erb +48 -0
  27. data/lib/putpaws/provision/provision_config.rb +156 -0
  28. data/lib/putpaws/provision/provision_task.rb +1 -0
  29. data/lib/putpaws/provision/resources/base.rb +49 -0
  30. data/lib/putpaws/provision/resources/cluster.rb +30 -0
  31. data/lib/putpaws/provision/resources/codebuild_project.rb +85 -0
  32. data/lib/putpaws/provision/resources/iam_role.rb +89 -0
  33. data/lib/putpaws/provision/resources/log_group.rb +50 -0
  34. data/lib/putpaws/provision/resources/security_group.rb +72 -0
  35. data/lib/putpaws/provision/resources/service.rb +71 -0
  36. data/lib/putpaws/provision/resources/task_definition.rb +119 -0
  37. data/lib/putpaws/provision/runner.rb +218 -0
  38. data/lib/putpaws/provision/state.rb +45 -0
  39. data/lib/putpaws/provision/tasks/provision.rake +129 -0
  40. data/lib/putpaws/provision/util.rb +50 -0
  41. data/lib/putpaws/tasks/info.rake +35 -0
  42. data/lib/putpaws/version.rb +1 -1
  43. metadata +80 -2
@@ -1,3 +1,4 @@
1
+ require 'socket'
1
2
  require 'aws-sdk-ecs'
2
3
  require 'aws-sdk-ssm'
3
4
 
@@ -8,18 +9,20 @@ module Putpaws::Ecs
8
9
  end
9
10
 
10
11
  attr_reader :ecs_client
11
- attr_reader :region, :cluster, :task_name_prefix
12
+ attr_reader :region, :cluster, :service, :task_name_prefix
12
13
  attr_accessor :ecs_task
13
- def initialize(region:, cluster:, task_name_prefix: nil)
14
+ def initialize(region:, cluster:, service: nil, task_name_prefix: nil)
14
15
  @ecs_client = Aws::ECS::Client.new({region: region})
15
16
  @region = region
16
17
  @cluster = cluster
18
+ @service = service
17
19
  @task_name_prefix = task_name_prefix
18
20
  @ecs_task = nil
19
21
  end
20
22
 
21
23
  def list_ecs_tasks
22
24
  res = ecs_client.list_tasks(cluster: cluster)
25
+ return [] if res.task_arns.empty?
23
26
  res = ecs_client.describe_tasks(tasks: res.task_arns, cluster: cluster)
24
27
  return res.tasks unless task_name_prefix
25
28
  res.tasks.select{|t|
@@ -28,6 +31,36 @@ module Putpaws::Ecs
28
31
  }
29
32
  end
30
33
 
34
+ def list_ecs_services
35
+ res = ecs_client.list_services(cluster: cluster, max_results: 100)
36
+ return [] if res.service_arns.empty?
37
+ res = ecs_client.describe_services(cluster: cluster, services: res.service_arns)
38
+ services = res.services.select{|s| s.status == 'ACTIVE'}
39
+ return services unless task_name_prefix
40
+ filtered = services.select{|s| s.service_name.start_with?(task_name_prefix)}
41
+ filtered.empty? ? services : filtered
42
+ end
43
+
44
+ def update_ecs_service(service:, desired_count: nil, task_definition: nil)
45
+ params = {
46
+ cluster: cluster,
47
+ service: service,
48
+ force_new_deployment: true,
49
+ }
50
+ params[:desired_count] = desired_count.to_i if desired_count
51
+ params[:task_definition] = task_definition if task_definition
52
+ res = ecs_client.update_service(**params)
53
+ res.service
54
+ end
55
+
56
+ def wait_ecs_service_stable(service:, timeout: 600)
57
+ ecs_client.wait_until(
58
+ :services_stable,
59
+ {cluster: cluster, services: [service]},
60
+ {delay: 15, max_attempts: (timeout / 15.0).ceil}
61
+ )
62
+ end
63
+
31
64
  def get_session_target(container: 'app')
32
65
  raise "ECS Task Not Set" unless ecs_task
33
66
  ctn = ecs_task.containers.detect{|c| c.name == container}
@@ -36,11 +69,31 @@ module Putpaws::Ecs
36
69
  "ecs:#{cluster}_#{task_id}_#{ctn.runtime_id}"
37
70
  end
38
71
 
72
+ def local_port_available?(port)
73
+ TCPServer.new('127.0.0.1', port.to_i).close
74
+ true
75
+ rescue Errno::EADDRINUSE, Errno::EACCES
76
+ false
77
+ end
78
+
79
+ def resolve_local_port(local_port)
80
+ if local_port
81
+ unless local_port_available?(local_port)
82
+ raise "Local port #{local_port} is already in use"
83
+ end
84
+ local_port.to_s
85
+ else
86
+ port = (1050..1079).to_a.shuffle.detect{|p| local_port_available?(p)}
87
+ raise "No available local port between 1050 and 1079. Please specify one like: local=:8080" unless port
88
+ port.to_s
89
+ end
90
+ end
91
+
39
92
  def get_port_forwarding_command(container: nil, remote_port:, remote_host:, local_port: nil)
40
93
  container ||= 'app'
41
94
  target = get_session_target(container: container)
42
95
  ssm_client = Aws::SSM::Client.new({region: region})
43
- local_port ||= (1050..1079).map(&:to_s).shuffle.first
96
+ local_port = resolve_local_port(local_port)
44
97
  puts "Starting to use local port: #{local_port}"
45
98
  res = ssm_client.start_session({
46
99
  target: target,
@@ -67,10 +120,12 @@ module Putpaws::Ecs
67
120
  build_session_manager_plugin_command(session: res.session, target: target)
68
121
  end
69
122
 
123
+ # Returns an argument array so that callers can spawn session-manager-plugin
124
+ # without a shell like `system(*cmd)`.
125
+ # https://github.com/aws/aws-cli/blob/2a6136010d8656a605d41d1e7b5fdab3c2930cad/awscli/customizations/ecs/executecommand.py#L105
70
126
  def build_session_manager_plugin_command(session:, target:)
71
127
  ssm_region = ENV['AWS_REGION_SSM'] || @region
72
128
 
73
- # https://github.com/aws/aws-cli/blob/2a6136010d8656a605d41d1e7b5fdab3c2930cad/awscli/customizations/ecs/executecommand.py#L105
74
129
  session_json = if session.respond_to?(:session_id)
75
130
  {
76
131
  "SessionId" => session.session_id,
@@ -85,16 +140,15 @@ module Putpaws::Ecs
85
140
  target_json = {
86
141
  "Target" => target
87
142
  }.to_json
88
- cmd = [
143
+ [
89
144
  "session-manager-plugin",
90
- session_json.dump,
145
+ session_json,
91
146
  @region,
92
147
  "StartSession",
93
- 'test',
94
- target_json.dump,
148
+ ENV['AWS_PROFILE'].to_s,
149
+ target_json,
95
150
  "https://ssm.#{ssm_region}.amazonaws.com"
96
151
  ]
97
- cmd.join(' ')
98
152
  end
99
153
  end
100
154
  end
@@ -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