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.
- checksums.yaml +4 -4
- data/README.md +153 -2
- data/lib/Putpawsfile +4 -0
- data/lib/putpaws/ai/ai_task.rb +1 -0
- data/lib/putpaws/ai/guide.rb +119 -0
- data/lib/putpaws/ai/tasks/ai_task.rake +14 -0
- data/lib/putpaws/application_config.rb +15 -2
- data/lib/putpaws/ecs/run_command.rb +115 -0
- data/lib/putpaws/ecs/task_command.rb +63 -9
- data/lib/putpaws/ecs/tasks/ecs_task.rake +113 -6
- data/lib/putpaws/iam/grant_command.rb +205 -0
- data/lib/putpaws/iam/iam_task.rb +1 -0
- data/lib/putpaws/iam/operator_config.rb +46 -0
- data/lib/putpaws/iam/tasks/iam_task.rake +65 -0
- data/lib/putpaws/info.rb +1 -0
- data/lib/putpaws/provision/all.rb +29 -0
- data/lib/putpaws/provision/aws_clients.rb +59 -0
- data/lib/putpaws/provision/config_writer.rb +88 -0
- data/lib/putpaws/provision/policy_generator.rb +221 -0
- data/lib/putpaws/provision/preset.rb +91 -0
- data/lib/putpaws/provision/presets/rails-nginx/preset.json +32 -0
- data/lib/putpaws/provision/presets/rails-nginx/templates/taskdef-app.json.erb +29 -0
- data/lib/putpaws/provision/presets/rails-nginx/templates/taskdef-web.json.erb +48 -0
- data/lib/putpaws/provision/provision_config.rb +156 -0
- data/lib/putpaws/provision/provision_task.rb +1 -0
- data/lib/putpaws/provision/resources/base.rb +49 -0
- data/lib/putpaws/provision/resources/cluster.rb +30 -0
- data/lib/putpaws/provision/resources/codebuild_project.rb +85 -0
- data/lib/putpaws/provision/resources/iam_role.rb +89 -0
- data/lib/putpaws/provision/resources/log_group.rb +50 -0
- data/lib/putpaws/provision/resources/security_group.rb +72 -0
- data/lib/putpaws/provision/resources/service.rb +71 -0
- data/lib/putpaws/provision/resources/task_definition.rb +119 -0
- data/lib/putpaws/provision/runner.rb +218 -0
- data/lib/putpaws/provision/state.rb +45 -0
- data/lib/putpaws/provision/tasks/provision.rake +129 -0
- data/lib/putpaws/provision/util.rb +50 -0
- data/lib/putpaws/tasks/info.rake +35 -0
- data/lib/putpaws/version.rb +1 -1
- 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
|
-
|
|
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
|
data/lib/putpaws/info.rb
ADDED
|
@@ -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
|