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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3af9788e3c54a5647c912f45ee76b78691c7f1ac7f7654e6af7b7f3ce51b16f4
4
- data.tar.gz: 51d6ea019d08c1139e3a865743d4435a304125a3a3b7ee2052332fc83aa170a3
3
+ metadata.gz: cbb1de2cdc7c119427ca5dfa073f5e8d07f504ff73f93a464f2912a8ef5168ed
4
+ data.tar.gz: 042f6202ae3cb0396b0ab7210b2d299504f7e099e8a0cf43d9c6314f0b6384ba
5
5
  SHA512:
6
- metadata.gz: 26c390300f9e4cb4fc2a970bbfbbc762e2de2bcfcd3fdb50ed8128e065caacecc99572e6f447ef244cb4c51a1cb4f844c0cc2a14aac361b678dc1e0b7ed20d54
7
- data.tar.gz: 3370e8ffe872b93910f951f12c29447b28ca411062d31eb3281740b786297505cc6c12408f11f780d10eefdc57bc4d58b3b0a381079eef6ea56d6b312021f4f8
6
+ metadata.gz: 12b7e736fe78e9a49569627fd296e9f5540cbea849f8b78a7edbb56c8652de73b82d1b2ac92fef279a682f64e4e83e061ba20e2c3afe1b3c99dce7fd35526fe1
7
+ data.tar.gz: efad6347afdc46833694e48070afd0330fe42f8bb0bc16638b6b88a7d5b6d1a091ce50f5ce1ddec4f92cc07e54796f42cc9802c2b5aa07ed4af1d96ba5ebd171
data/README.md CHANGED
@@ -1,7 +1,116 @@
1
- # Put your paws up!!
1
+ # Put your pAWS up!!
2
+
3
+ ## Command names
4
+
5
+ The short commands follow a "put your paws ..." image:
6
+
7
+ - `putpaws ready` ... put paws ready: — place your paws and prepare
8
+ - `putpaws steady` ... put paws steady: — hold steady, right before the start
9
+ - `putpaws up` ... put paws up: — everything is ready, raise them up
10
+ - `putpaws ahead` ... put paws ahead: — reach ahead and probe (dry-run)
11
+ - `putpaws away` ... put paws away: — take your paws off and let AI take over
12
+
13
+ ## Provisioning
14
+
15
+ Build service infra (security group / ECS cluster / task definitions / service / log groups / CodeBuild) idempotently.
16
+ Deletion is out of scope on purpose: putpaws only creates and updates.
17
+
18
+ ```
19
+ # Interview basic infra (existing VPC / ECR / SES / S3 / SSM parameter prefix)
20
+ # and generate provision.json + IAM role drafts
21
+ bundle exec putpaws ready
22
+
23
+ # After reviewing/editing the drafts under policies/, create the IAM roles
24
+ # from them as-is and fill their ARNs into provision.json. Idempotent:
25
+ # edit a draft and run again to update the role.
26
+ bundle exec putpaws steady
27
+
28
+ # Dry-run: show what would be created / updated / skipped
29
+ bundle exec putpaws ahead
30
+
31
+ # Build the rest as far as possible. `up` stops with instructions where a manual
32
+ # step is required. Fix things by hand, then just run `up` again. Safe to re-run any time.
33
+ bundle exec putpaws up
34
+ ```
35
+
36
+ Running `steady` is your explicit confirmation of the drafts: `up` never touches IAM
37
+ and stops until the role ARNs are filled. Filling in an existing role ARN in
38
+ provision.json instead makes `steady` leave that role alone
39
+ (useful when IAM is managed by another team).
40
+
41
+ Files live under `.putpaws/provisioning/`:
42
+
43
+ - `provisioning/{service_name}/provision.json` ... inputs (edit by hand; `steady` fills the role ARNs)
44
+ - `provisioning/{service_name}/state.json` ... created resource info (written by putpaws)
45
+ - `provisioning/{service_name}/policies/` ... IAM role drafts to review, applied as-is by `putpaws steady`
46
+ - `provisioning/presets/{preset}/` ... defaults and task definition templates (copied here on `ready`, edit freely)
47
+
48
+ Presets are searched in this order: project local (`.putpaws/provisioning/presets/`),
49
+ user global (directories in `PUTPAWS_PRESETS_PATH`, then `~/.putpaws/presets/`), and the ones bundled in the gem.
50
+ Whichever you pick on `ready` is copied into the project so the project owns its snapshot.
51
+
52
+ Security groups are either specified (one or more existing ones; putpaws never touches their rules)
53
+ or a new one is created for the service when left empty on `ready`.
54
+
55
+ When the service step completes, the new service is reflected into
56
+ `.putpaws/application.json` and `.putpaws/infra.json` (diff is shown and confirmed),
57
+ so `ecs:attach`, `ecs:shell`, `ecs:run` and `log:*` work immediately.
58
+ Secrets are handled by reference only: task definitions point to existing
59
+ SSM parameters (Ex: `/{service_name}/RAILS_MASTER_KEY`) and putpaws never touches the values.
60
+
61
+ A scheduler role is always created too, and the `target` entry of infra.json carries it,
62
+ so `scheduler:deploy` works any time by just writing `.putpaws/schedule.json`.
63
+
64
+ CodeBuild is created as CI: builds run inside the VPC with the same subnets and
65
+ security group as the service, so `db:migrate` against RDS works from the build.
66
+ Note that the private subnets need a NAT gateway (or VPC endpoints) so that
67
+ builds can reach GitHub / ECR / CloudWatch Logs.
68
+
69
+ ## Operator permissions
70
+
71
+ Declare named operator profiles in `.putpaws/operators.json` (generated with examples on first run),
72
+ then resolve them into managed policies per service, idempotently
73
+ (CREATE / UPDATE as a new policy version / SKIP — re-running never piles up policies):
74
+
75
+ ```
76
+ bundle exec putpaws awesome-api-staging iam:grant profile=developer
77
+ ```
78
+
79
+ ```
80
+ {
81
+ "developer": { "groups": ["attach", "shell", "deploy", "logs", "codebuild"] },
82
+ "viewer": { "groups": ["logs"] }
83
+ }
84
+ ```
85
+
86
+ Groups map to putpaws command namespaces: `attach` (ecs:attach/forward), `shell` (ecs:shell/run —
87
+ note this implies reading all secrets of the service), `deploy` (ecs:deploy), `logs` (log:*),
88
+ `codebuild` (code_build:build), `scheduler` (scheduler:deploy).
89
+ `extra_statements` on a profile appends raw IAM statements.
90
+ The policy is named `{service}-operator-{profile}`. Attaching it to users/groups is left to your admin
91
+ (an attach command example is printed).
92
+
93
+ ## AI setup
94
+
95
+ `putpaws away` installs a guide for AI agents (Claude Code etc.) into the project:
96
+ `.claude/skills/putpaws/SKILL.md` (commands, read-only vs mutating classification,
97
+ non-interactive recipes, suggested permission allowlist) and a pointer block in
98
+ `CLAUDE.md` (or `AGENTS.md` when there is no CLAUDE.md). Re-run it after updating putpaws.
99
+
100
+ ```
101
+ bundle exec putpaws away
102
+ ```
2
103
 
3
104
  ## Example
4
105
 
106
+ ### Info
107
+
108
+ Show resolved settings for a service without touching AWS
109
+
110
+ ```
111
+ bundle exec putpaws awesome-api-staging info
112
+ ```
113
+
5
114
  ### ECS
6
115
 
7
116
  Attach to staging specific container
@@ -21,6 +130,46 @@ bundle exec putpaws awesome-api-staging ecs:forward container=app remote=example
21
130
  mysql -u awesome_user -p --port 1050 -h 127.0.0.1
22
131
  ```
23
132
 
133
+ Redeploy the service (update-service with force new deployment).
134
+ With a mutable image tag like `latest`, this rolls out the newly pushed image.
135
+
136
+ ```
137
+ bundle exec putpaws awesome-api-staging ecs:deploy
138
+
139
+ # Optionally change desired count / task definition, and wait until stable
140
+ bundle exec putpaws awesome-api-staging ecs:deploy desired=2 taskdef=awesome-api-staging-web wait=true
141
+ ```
142
+
143
+ Run a command on a temporary task. The task terminates itself when the command finishes.
144
+
145
+ ```
146
+ bundle exec putpaws awesome-api-staging ecs:run cmd='bundle exec rake db:migrate'
147
+
148
+ # Pass wait=true to wait until the task stops and check the exit code
149
+ bundle exec putpaws awesome-api-staging ecs:run cmd='bundle exec rake db:migrate' wait=true
150
+ ```
151
+
152
+ Launch a temporary task for operation and attach to it (like SSH-ing into the environment).
153
+ The task is launched with its command overridden by `sleep <ttl>`, so it always terminates itself when ttl passes even if your shell is gone.
154
+
155
+ ```
156
+ # ttl is 30 minutes by default
157
+ bundle exec putpaws awesome-api-staging ecs:shell
158
+
159
+ # Specify ttl: "45" and "45m" mean 45 minutes, "90s" and "2h" also work
160
+ bundle exec putpaws awesome-api-staging ecs:shell ttl=45m
161
+
162
+ # The task is stopped immediately when you exit the shell.
163
+ # Pass keep=true to keep it running until ttl passes.
164
+ bundle exec putpaws awesome-api-staging ecs:shell keep=true
165
+ ```
166
+
167
+ Temporary tasks are launched with `group` / `started_by` set to `putpaws-run` or `putpaws-shell`,
168
+ so you can identify them on the AWS console and on the task list of `ecs:attach` / `ecs:forward`.
169
+
170
+ `ecs:run` and `ecs:shell` require `network` and `target` in `.putpaws/application.json`,
171
+ which refer to the `network` / `target` sections of `.putpaws/infra.json` (the same ones used by scheduler).
172
+
24
173
  ### CloudWatch Logs
25
174
 
26
175
  `tail -f`
@@ -65,7 +214,9 @@ gem 'putpaws'
65
214
  "log_group_prefix": "/ecs/awesome/awesome-api-staging",
66
215
  "log_region": null,
67
216
  "build_log_group_prefix": "/aws/codebuild/awesome-api-staging",
68
- "build_project_name_prefix": "awesome-api-staging"
217
+ "build_project_name_prefix": "awesome-api-staging",
218
+ "network": "awesome-private-staging",
219
+ "target": "awesome-staging"
69
220
  },
70
221
  "awesome-api-production": {
71
222
  "region": "ap-northeast-1",
data/lib/Putpawsfile CHANGED
@@ -6,3 +6,7 @@ require "putpaws/ecs/ecs_task"
6
6
  require "putpaws/cloud_watch/log_task"
7
7
  require "putpaws/scheduler/scheduler_task"
8
8
  require "putpaws/code_build/build_task"
9
+ require "putpaws/provision/provision_task"
10
+ require "putpaws/iam/iam_task"
11
+ require "putpaws/info"
12
+ require "putpaws/ai/ai_task"
@@ -0,0 +1 @@
1
+ load File.expand_path("../tasks/ai_task.rake", __FILE__)
@@ -0,0 +1,119 @@
1
+ require 'fileutils'
2
+ require 'putpaws/version'
3
+
4
+ module Putpaws
5
+ module Ai
6
+ # `putpaws away`: put paws away — take your paws off and let AI take over.
7
+ # Installs a guide for AI agents (Claude Code etc.) into the host project:
8
+ # a skill file plus a pointer block in CLAUDE.md / AGENTS.md.
9
+ # Re-runnable: everything is regenerated in place.
10
+ class Guide
11
+ SKILL_PATH = File.join('.claude', 'skills', 'putpaws', 'SKILL.md')
12
+ BEGIN_MARK = '<!-- putpaws:begin -->'
13
+ END_MARK = '<!-- putpaws:end -->'
14
+
15
+ def self.install!(root: '.', services: nil)
16
+ services ||= Putpaws::ApplicationConfig.all.map(&:name)
17
+ skill_path = File.join(root, SKILL_PATH)
18
+ FileUtils.mkdir_p(File.dirname(skill_path))
19
+ File.write(skill_path, skill_content(services: services))
20
+ pointer_path = pointer_target(root)
21
+ update_pointer!(pointer_path)
22
+ [skill_path, pointer_path]
23
+ end
24
+
25
+ # Prefer an existing CLAUDE.md; otherwise use AGENTS.md (created if absent).
26
+ def self.pointer_target(root)
27
+ claude = File.join(root, 'CLAUDE.md')
28
+ return claude if File.exist?(claude)
29
+ File.join(root, 'AGENTS.md')
30
+ end
31
+
32
+ def self.update_pointer!(path)
33
+ block = pointer_block
34
+ content = File.exist?(path) ? File.read(path) : ''
35
+ if content.include?(BEGIN_MARK)
36
+ content = content.sub(/#{Regexp.escape(BEGIN_MARK)}.*#{Regexp.escape(END_MARK)}/m, block.strip)
37
+ else
38
+ content = content.empty? ? block : content.rstrip + "\n\n" + block
39
+ end
40
+ File.write(path, content)
41
+ end
42
+
43
+ def self.pointer_block
44
+ <<~MD
45
+ #{BEGIN_MARK}
46
+ ## putpaws (AWS operations)
47
+
48
+ This project uses the putpaws gem for AWS/ECS operations (logs, deploy, migrations, one-off tasks).
49
+ Read `.claude/skills/putpaws/SKILL.md` for commands and safety notes before running AWS operations.
50
+ Quick reference: services are the top-level keys of `.putpaws/application.json`;
51
+ `bundle exec putpaws <service> info` shows resolved settings without touching AWS.
52
+ #{END_MARK}
53
+ MD
54
+ end
55
+
56
+ def self.skill_content(services: [])
57
+ services_line = services.empty? ? '(none configured yet — check .putpaws/application.json)' : services.join(', ')
58
+ <<~MD
59
+ ---
60
+ name: putpaws
61
+ description: AWS operations for this project (CloudWatch logs, ECS deploy, db:migrate and one-off tasks on Fargate, CodeBuild CI) via the putpaws CLI. Use when asked to investigate logs, deploy, run rake tasks on AWS, or inspect ECS services.
62
+ ---
63
+
64
+ # putpaws
65
+
66
+ Capistrano-style AWS operation commands. General form:
67
+
68
+ bundle exec putpaws <service> <command> key=value
69
+
70
+ Services in this project: #{services_line}
71
+ (services are the top-level keys of `.putpaws/application.json`)
72
+
73
+ Discover commands with `bundle exec putpaws -T` (or `-D` for full descriptions).
74
+
75
+ ## Read-only commands (safe to run without asking)
76
+
77
+ - `bundle exec putpaws <service> info` — resolved settings, no AWS access
78
+ - `bundle exec putpaws <service> log:tail since=2h` — CloudWatch logs (units: s/m/h/d/w, add `for=1h` to bound the range)
79
+ - `bundle exec putpaws <service> log:tailf` — follow logs (long-running; prefer `log:tail` in automation)
80
+ - `bundle exec putpaws ahead` — dry-run of provisioning (CREATE/UPDATE/SKIP)
81
+
82
+ ## Mutating commands (confirm with the user before running)
83
+
84
+ - `bundle exec putpaws <service> ecs:run cmd='bundle exec rake db:migrate' wait=true`
85
+ — runs a one-off command on a temporary Fargate task and exits non-zero on failure.
86
+ The best choice for migrations and rake tasks. Fully non-interactive.
87
+ - `bundle exec putpaws <service> ecs:deploy wait=true` — redeploy the service (force new deployment)
88
+ - `bundle exec putpaws <service> code_build:build branch=main` — start the CI build (build + push + deploy per buildspec)
89
+ - `bundle exec putpaws <service> scheduler:deploy` — deploy schedules from `.putpaws/schedule.json`
90
+
91
+ ## Interactive commands (avoid in non-TTY sessions; meant for humans)
92
+
93
+ - `ecs:attach`, `ecs:shell`, `ecs:forward` — open live sessions into containers
94
+ - `ready`, `steady`, `up`, `iam:grant` — provisioning flows with confirmation prompts
95
+
96
+ ## Config files (source of truth, safe to read)
97
+
98
+ - `.putpaws/application.json` — services and their cluster/log/build settings
99
+ - `.putpaws/infra.json` — network (subnets/security groups) and target (cluster/taskdef) definitions
100
+ - `.putpaws/schedule.json` — named schedules, `.putpaws/operators.json` — operator permission profiles
101
+ - `.putpaws/provisioning/<service>/` — provisioning inputs (provision.json), state, IAM role drafts
102
+
103
+ ## Suggested permission allowlist (.claude/settings.json)
104
+
105
+ "permissions": {
106
+ "allow": [
107
+ "Bash(bundle exec putpaws -T*)",
108
+ "Bash(bundle exec putpaws * info)",
109
+ "Bash(bundle exec putpaws * log:*)"
110
+ ]
111
+ }
112
+
113
+ ---
114
+ Generated by putpaws #{Putpaws::VERSION}. Refresh with `bundle exec putpaws away` after updating the gem.
115
+ MD
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,14 @@
1
+ require "putpaws/ai/guide"
2
+
3
+ namespace :ai do
4
+ desc "Install the AI agent guide (.claude/skills/putpaws/SKILL.md + a pointer in CLAUDE.md/AGENTS.md). Re-runnable. (alias: away)"
5
+ task :install do
6
+ files = Putpaws::Ai::Guide.install!
7
+ puts "Generated:"
8
+ files.each{|f| puts " #{f}"}
9
+ puts "Re-run `bundle exec putpaws away` after updating putpaws to refresh the guide."
10
+ end
11
+ end
12
+
13
+ desc "Alias of ai:install"
14
+ task away: 'ai:install'
@@ -1,13 +1,16 @@
1
1
  require 'json'
2
2
  require 'pathname'
3
3
  require "putpaws/schedule_config"
4
+ require "putpaws/infra_network_config"
5
+ require "putpaws/infra_target_config"
4
6
 
5
7
  class Putpaws::ApplicationConfig < Struct.new(
6
- :name, :region,
8
+ :name, :region,
7
9
  :cluster, :service, :task_name_prefix, :ecs_region,
8
10
  :log_group_prefix, :log_region,
9
11
  :build_project_name_prefix, :build_log_group_prefix, :build_region,
10
12
  :schedules,
13
+ :network, :target,
11
14
  keyword_init: true)
12
15
  def self.load(path_prefix: '.putpaws')
13
16
  @application_data ||= begin
@@ -37,7 +40,7 @@ class Putpaws::ApplicationConfig < Struct.new(
37
40
  {
38
41
  region: ecs_region || region,
39
42
  cluster: cluster,
40
- # service: service,
43
+ service: service,
41
44
  task_name_prefix: task_name_prefix,
42
45
  }
43
46
  end
@@ -60,6 +63,16 @@ class Putpaws::ApplicationConfig < Struct.new(
60
63
  @schedules ||= (self[:schedules] || []).map{|x| Putpaws::ScheduleConfig.find(x)}.compact
61
64
  end
62
65
 
66
+ def network
67
+ return nil unless self[:network]
68
+ @network ||= Putpaws::InfraNetworkConfig.find(self[:network])
69
+ end
70
+
71
+ def target
72
+ return nil unless self[:target]
73
+ @target ||= Putpaws::InfraTargetConfig.find(self[:target])
74
+ end
75
+
63
76
  def codebuild_command_params
64
77
  {
65
78
  region: build_region || region,
@@ -0,0 +1,115 @@
1
+ require 'aws-sdk-ecs'
2
+
3
+ module Putpaws::Ecs
4
+ class RunCommand
5
+ DEFAULT_TTL = '30m'
6
+ SECONDS = {
7
+ 's' => 1,
8
+ 'm' => 60,
9
+ 'h' => 60 * 60,
10
+ }
11
+
12
+ def self.config(config)
13
+ unless config.network && config.target
14
+ raise "Please set `network` and `target` at .putpaws/application.json\n" +
15
+ "They refer to `network` and `target` sections at .putpaws/infra.json\n" +
16
+ "(Ex) \"network\": \"awesome-private-staging\", \"target\": \"awesome-staging\""
17
+ end
18
+ new(
19
+ region: config.ecs_region || config.region,
20
+ network: config.network,
21
+ target: config.target,
22
+ )
23
+ end
24
+
25
+ # "45" and "45m" mean 45 minutes. "90s" and "2h" are also acceptable.
26
+ def self.parse_ttl(ut)
27
+ ut = DEFAULT_TTL if ut.nil? || ut.strip.empty?
28
+ matched, number, unit = ut.strip.match(/\A(\d+)([smh])?\z/).to_a
29
+ raise "Invalid ttl: #{ut} (Ex) ttl=30m ttl=2h ttl=90s" unless matched
30
+ number.to_i * SECONDS[unit || 'm']
31
+ end
32
+
33
+ attr_reader :ecs_client
34
+ attr_reader :region, :network, :target
35
+ def initialize(region:, network:, target:)
36
+ @ecs_client = Aws::ECS::Client.new({region: region})
37
+ @region = region
38
+ @network = network
39
+ @target = target
40
+ end
41
+
42
+ def cluster_name
43
+ target.cluster.split('/').last
44
+ end
45
+
46
+ def container_name
47
+ target.container_name || 'app'
48
+ end
49
+
50
+ # Launch a temporary task whose main process is overridden with the given
51
+ # command, so that the task terminates itself when the command finishes.
52
+ # started_by and group are set so that temporary tasks are identifiable.
53
+ def run_ecs_task(command:, started_by:, group:, container: nil)
54
+ res = ecs_client.run_task({
55
+ cluster: target.cluster,
56
+ task_definition: target.task_definition,
57
+ count: 1,
58
+ launch_type: 'FARGATE',
59
+ enable_execute_command: true,
60
+ started_by: started_by,
61
+ group: group,
62
+ network_configuration: {
63
+ awsvpc_configuration: {
64
+ subnets: network.subnets,
65
+ security_groups: network.security_groups,
66
+ assign_public_ip: network.assign_public_ip,
67
+ }
68
+ },
69
+ overrides: {
70
+ container_overrides: [
71
+ {
72
+ name: container || container_name,
73
+ command: ['/bin/sh', '-c', command],
74
+ }
75
+ ]
76
+ },
77
+ })
78
+ failure = res.failures.first
79
+ raise "Failed to run task: #{failure.reason} #{failure.detail}" if failure
80
+ res.tasks.first
81
+ end
82
+
83
+ def wait_until_attachable(task_arn, timeout: 600, interval: 5)
84
+ deadline = Time.now + timeout
85
+ loop do
86
+ res = ecs_client.describe_tasks(cluster: target.cluster, tasks: [task_arn])
87
+ task = res.tasks.first
88
+ if task.nil? || task.last_status == 'STOPPED'
89
+ raise "Task was stopped: #{task && task.stopped_reason}"
90
+ end
91
+ ctn = task.containers.detect{|c| c.name == container_name}
92
+ agent = ctn && (ctn.managed_agents || []).detect{|a| a.name == 'ExecuteCommandAgent'}
93
+ return task if task.last_status == 'RUNNING' && agent && agent.last_status == 'RUNNING'
94
+ raise "Timed out waiting for task to be attachable: #{task_arn}" if Time.now > deadline
95
+ sleep interval
96
+ end
97
+ end
98
+
99
+ def wait_until_stopped(task_arn, timeout: 3600, interval: 10)
100
+ deadline = Time.now + timeout
101
+ loop do
102
+ res = ecs_client.describe_tasks(cluster: target.cluster, tasks: [task_arn])
103
+ task = res.tasks.first
104
+ raise "Task not found: #{task_arn}" unless task
105
+ return task if task.last_status == 'STOPPED'
106
+ raise "Timed out waiting for task to stop: #{task_arn}" if Time.now > deadline
107
+ sleep interval
108
+ end
109
+ end
110
+
111
+ def stop_ecs_task(task_arn, reason: 'Stopped by putpaws')
112
+ ecs_client.stop_task(cluster: target.cluster, task: task_arn, reason: reason)
113
+ end
114
+ end
115
+ end
@@ -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