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
@@ -0,0 +1,85 @@
1
+ require 'putpaws/provision/util'
2
+ require 'putpaws/provision/resources/base'
3
+
4
+ module Putpaws
5
+ module Provision
6
+ module Resources
7
+ class CodebuildProject < Base
8
+ def name
9
+ config.build_project_name
10
+ end
11
+
12
+ def devops
13
+ config.devops_settings
14
+ end
15
+
16
+ def security_group_ids
17
+ ids = state.resources[:security_group_ids]
18
+ raise "security_group_ids not found in state. Run the service step first." if ids.nil? || ids.empty?
19
+ ids
20
+ end
21
+
22
+ def desired_params
23
+ {
24
+ name: name,
25
+ description: "Managed by putpaws for #{config.service_name}",
26
+ source: {
27
+ type: devops[:source_type] || 'GITHUB',
28
+ location: devops[:source_repository],
29
+ buildspec: devops[:buildspec] || 'buildspec.yml',
30
+ },
31
+ artifacts: {type: 'NO_ARTIFACTS'},
32
+ environment: {
33
+ type: 'LINUX_CONTAINER',
34
+ image: devops[:environment_image] || 'aws/codebuild/standard:7.0',
35
+ compute_type: devops[:compute_type] || 'BUILD_GENERAL1_SMALL',
36
+ privileged_mode: devops.fetch(:privileged_mode, true),
37
+ },
38
+ service_role: config.roles[:codebuild_role_arn],
39
+ logs_config: {
40
+ cloud_watch_logs: {status: 'ENABLED', group_name: config.build_log_group}
41
+ },
42
+ # CI needs VPC access (Ex: db:migrate against RDS). Reuse the
43
+ # service security group so the build reaches whatever the app can.
44
+ vpc_config: {
45
+ vpc_id: config.base[:vpc_id],
46
+ subnets: config.base[:subnets],
47
+ security_group_ids: security_group_ids,
48
+ },
49
+ tags: [MANAGED_TAG],
50
+ }
51
+ end
52
+
53
+ def current
54
+ res = clients.codebuild.batch_get_projects(names: [name])
55
+ res.projects.first
56
+ end
57
+
58
+ def changed?(current)
59
+ current.source&.type != desired_params[:source][:type] ||
60
+ current.source&.location != desired_params[:source][:location] ||
61
+ current.source&.buildspec != desired_params[:source][:buildspec] ||
62
+ current.service_role != desired_params[:service_role] ||
63
+ current.environment&.image != desired_params[:environment][:image] ||
64
+ current.environment&.compute_type != desired_params[:environment][:compute_type] ||
65
+ current.vpc_config&.subnets.to_a.sort != desired_params[:vpc_config][:subnets].sort ||
66
+ current.vpc_config&.security_group_ids.to_a.sort != desired_params[:vpc_config][:security_group_ids].sort
67
+ end
68
+
69
+ def create!
70
+ res = clients.codebuild.create_project(**desired_params)
71
+ {codebuild_project: res.project.name}
72
+ end
73
+
74
+ def update!(_current)
75
+ res = clients.codebuild.update_project(**desired_params)
76
+ {codebuild_project: res.project.name}
77
+ end
78
+
79
+ def outputs(current)
80
+ {codebuild_project: current.name}
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,89 @@
1
+ require 'json'
2
+ require 'digest'
3
+ require 'putpaws/provision/resources/base'
4
+
5
+ module Putpaws
6
+ module Provision
7
+ module Resources
8
+ # Registers an IAM role from a reviewed draft file under policies/.
9
+ # This runs only after the user approved the drafts explicitly
10
+ # (roles.approve_drafts in provision.json). The draft content is applied
11
+ # as-is, including any edits the user made.
12
+ class IamRole < Base
13
+ attr_reader :roles_key, :draft_file
14
+ def initialize(roles_key:, draft_file:, **args)
15
+ super(**args)
16
+ @roles_key = roles_key
17
+ @draft_file = draft_file
18
+ end
19
+
20
+ def draft
21
+ @draft ||= begin
22
+ path = config.policies_dir.join(draft_file)
23
+ raise "Role draft not found: #{path}. Please run `putpaws ready` first." unless path.exist?
24
+ JSON.parse(File.read(path), symbolize_names: true)
25
+ end
26
+ end
27
+
28
+ def name
29
+ draft[:SuggestedRoleName]
30
+ end
31
+
32
+ def policy_name
33
+ draft[:SuggestedPolicyName]
34
+ end
35
+
36
+ def digest
37
+ Digest::SHA256.hexdigest(JSON.generate(draft))
38
+ end
39
+
40
+ def digest_key
41
+ :"iam_#{roles_key}_digest"
42
+ end
43
+
44
+ def current
45
+ clients.iam.get_role(role_name: name).role
46
+ rescue Aws::IAM::Errors::NoSuchEntityException
47
+ nil
48
+ end
49
+
50
+ # Re-applied only when the draft content changed since the last apply,
51
+ # so that manual changes on the AWS side are left alone otherwise.
52
+ def changed?(_current)
53
+ state.resources[digest_key] != digest
54
+ end
55
+
56
+ def create!
57
+ res = clients.iam.create_role(
58
+ role_name: name,
59
+ assume_role_policy_document: JSON.generate(draft[:AssumeRolePolicyDocument]),
60
+ tags: [MANAGED_TAG]
61
+ )
62
+ put_policy!
63
+ {roles_key => res.role.arn, digest_key => digest}
64
+ end
65
+
66
+ def update!(current)
67
+ clients.iam.update_assume_role_policy(
68
+ role_name: name,
69
+ policy_document: JSON.generate(draft[:AssumeRolePolicyDocument])
70
+ )
71
+ put_policy!
72
+ {roles_key => current.arn, digest_key => digest}
73
+ end
74
+
75
+ def outputs(current)
76
+ {roles_key => current.arn, digest_key => digest}
77
+ end
78
+
79
+ def put_policy!
80
+ clients.iam.put_role_policy(
81
+ role_name: name,
82
+ policy_name: policy_name,
83
+ policy_document: JSON.generate(draft[:PolicyDocument])
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,50 @@
1
+ require 'putpaws/provision/resources/base'
2
+
3
+ module Putpaws
4
+ module Provision
5
+ module Resources
6
+ class LogGroup < Base
7
+ def initialize(log_group_name:, output_key:, **args)
8
+ super(**args)
9
+ @log_group_name = log_group_name
10
+ @output_key = output_key
11
+ end
12
+
13
+ def name
14
+ @log_group_name
15
+ end
16
+
17
+ def retention_in_days
18
+ (config.settings[:log_retention_days] || 30).to_i
19
+ end
20
+
21
+ def current
22
+ res = clients.logs.describe_log_groups(log_group_name_prefix: name)
23
+ res.log_groups.detect{|g| g.log_group_name == name}
24
+ end
25
+
26
+ def changed?(current)
27
+ current.retention_in_days != retention_in_days
28
+ end
29
+
30
+ def create!
31
+ clients.logs.create_log_group(
32
+ log_group_name: name,
33
+ tags: {MANAGED_TAG[:key] => MANAGED_TAG[:value]}
34
+ )
35
+ clients.logs.put_retention_policy(log_group_name: name, retention_in_days: retention_in_days)
36
+ {@output_key => name}
37
+ end
38
+
39
+ def update!(_current)
40
+ clients.logs.put_retention_policy(log_group_name: name, retention_in_days: retention_in_days)
41
+ {@output_key => name}
42
+ end
43
+
44
+ def outputs(_current)
45
+ {@output_key => name}
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,72 @@
1
+ require 'putpaws/provision/util'
2
+ require 'putpaws/provision/resources/base'
3
+
4
+ module Putpaws
5
+ module Provision
6
+ module Resources
7
+ # When base.security_group_ids is set, the existing security groups are
8
+ # used as-is (putpaws never touches their rules). Otherwise a new one is
9
+ # created with no ingress rules (egress open by default).
10
+ # Ingress for ALB etc. is out of scope: add manually or by another command.
11
+ class SecurityGroup < Base
12
+ def specified_ids
13
+ ids = Array(config.base[:security_group_ids])
14
+ # backward compatible with the singular key
15
+ ids = [config.base[:security_group_id]] if ids.empty?
16
+ ids.reject{|x| Util.blank?(x)}
17
+ end
18
+
19
+ def specified?
20
+ specified_ids.any?
21
+ end
22
+
23
+ def name
24
+ specified? ? "#{specified_ids.join(', ')} (existing)" : config.sg_name
25
+ end
26
+
27
+ def current
28
+ return current_specified if specified?
29
+ res = clients.ec2.describe_security_groups(filters: [
30
+ {name: 'group-name', values: [name]},
31
+ {name: 'vpc-id', values: [config.base[:vpc_id]]},
32
+ ])
33
+ res.security_groups.first
34
+ end
35
+
36
+ def current_specified
37
+ res = begin
38
+ clients.ec2.describe_security_groups(group_ids: specified_ids)
39
+ rescue Aws::EC2::Errors::InvalidGroupNotFound
40
+ raise "Specified security group not found: #{specified_ids.join(', ')}"
41
+ end
42
+ found = res.security_groups
43
+ missing = specified_ids - found.map(&:group_id)
44
+ raise "Specified security group not found: #{missing.join(', ')}" if missing.any?
45
+ wrong = found.reject{|sg| sg.vpc_id == config.base[:vpc_id]}
46
+ if wrong.any?
47
+ sg = wrong.first
48
+ raise "Security group #{sg.group_id} belongs to #{sg.vpc_id}, not #{config.base[:vpc_id]}"
49
+ end
50
+ found
51
+ end
52
+
53
+ def create!
54
+ res = clients.ec2.create_security_group(
55
+ group_name: name,
56
+ description: "Managed by putpaws for #{config.service_name}",
57
+ vpc_id: config.base[:vpc_id],
58
+ tag_specifications: [
59
+ {resource_type: 'security-group', tags: [MANAGED_TAG]}
60
+ ]
61
+ )
62
+ {security_group_ids: [res.group_id]}
63
+ end
64
+
65
+ def outputs(current)
66
+ ids = current.is_a?(Array) ? current.map(&:group_id) : [current.group_id]
67
+ {security_group_ids: ids}
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,71 @@
1
+ require 'putpaws/provision/resources/base'
2
+
3
+ module Putpaws
4
+ module Provision
5
+ module Resources
6
+ class Service < Base
7
+ def name
8
+ config.service_name
9
+ end
10
+
11
+ def desired_count
12
+ (config.settings[:desired_count] || 1).to_i
13
+ end
14
+
15
+ def task_definition_family
16
+ config.service_task_definition_family
17
+ end
18
+
19
+ def security_group_ids
20
+ ids = state.resources[:security_group_ids]
21
+ raise "security_group_ids not found in state. Run the security group step first." if ids.nil? || ids.empty?
22
+ ids
23
+ end
24
+
25
+ def current
26
+ res = clients.ecs.describe_services(cluster: config.cluster_name, services: [name])
27
+ res.services.detect{|s| s.status == 'ACTIVE'}
28
+ end
29
+
30
+ def changed?(current)
31
+ current_family = current.task_definition.to_s.split('/').last.to_s.split(':').first
32
+ current_family != task_definition_family || current.desired_count != desired_count
33
+ end
34
+
35
+ def create!
36
+ res = clients.ecs.create_service(
37
+ cluster: config.cluster_name,
38
+ service_name: name,
39
+ task_definition: task_definition_family,
40
+ desired_count: desired_count,
41
+ launch_type: 'FARGATE',
42
+ enable_execute_command: true,
43
+ network_configuration: {
44
+ awsvpc_configuration: {
45
+ subnets: config.base[:subnets],
46
+ security_groups: security_group_ids,
47
+ assign_public_ip: config.settings[:assign_public_ip] || 'DISABLED',
48
+ }
49
+ },
50
+ tags: [MANAGED_TAG]
51
+ )
52
+ {service_arn: res.service.service_arn, service_name: name}
53
+ end
54
+
55
+ def update!(_current)
56
+ res = clients.ecs.update_service(
57
+ cluster: config.cluster_name,
58
+ service: name,
59
+ task_definition: task_definition_family,
60
+ desired_count: desired_count
61
+ )
62
+ {service_arn: res.service.service_arn, service_name: name}
63
+ end
64
+
65
+ def outputs(current)
66
+ {service_arn: current.service_arn, service_name: name}
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,119 @@
1
+ require 'erb'
2
+ require 'json'
3
+ require 'digest'
4
+ require 'putpaws/provision/util'
5
+ require 'putpaws/provision/resources/base'
6
+
7
+ module Putpaws
8
+ module Provision
9
+ module Resources
10
+ class TaskDefinition < Base
11
+ # Variables exposed to taskdef ERB templates.
12
+ class TemplateContext
13
+ attr_reader :service_name, :region, :account_id,
14
+ :cpu, :memory, :container_port, :image, :nginx_image,
15
+ :log_group, :execution_role_arn, :task_role_arn,
16
+ :env_json, :secrets_json
17
+
18
+ def initialize(config)
19
+ settings = config.settings
20
+ @service_name = config.service_name
21
+ @region = config.region
22
+ @account_id = config.account_id
23
+ @cpu = settings[:cpu].to_s
24
+ @memory = settings[:memory].to_s
25
+ @container_port = (settings[:container_port] || 3000).to_i
26
+ @image = config.ecr_image_uri
27
+ @nginx_image = settings[:nginx_image] || 'public.ecr.aws/nginx/nginx:stable'
28
+ @log_group = config.log_group
29
+ @execution_role_arn = config.roles[:task_execution_role_arn]
30
+ @task_role_arn = config.roles[:task_role_arn]
31
+ @env_json = JSON.generate(
32
+ (settings[:env] || {}).map{|k, v| {name: k.to_s, value: v.to_s}}
33
+ )
34
+ @secrets_json = JSON.generate(
35
+ config.resolved_secrets.map{|name, path|
36
+ {name: name, valueFrom: "arn:aws:ssm:#{config.region}:#{config.account_id}:parameter#{path}"}
37
+ }
38
+ )
39
+ end
40
+
41
+ def get_binding
42
+ binding
43
+ end
44
+ end
45
+
46
+ attr_reader :entry
47
+ def initialize(entry:, **args)
48
+ super(**args)
49
+ @entry = entry
50
+ end
51
+
52
+ def suffix
53
+ entry[:suffix]
54
+ end
55
+
56
+ def name
57
+ config.task_definition_family(suffix)
58
+ end
59
+
60
+ def desired_params
61
+ @desired_params ||= begin
62
+ template = File.read(config.preset.template_path(entry[:template]))
63
+ rendered = ERB.new(template, trim_mode: '-').result(TemplateContext.new(config).get_binding)
64
+ params = Util.deep_underscore_keys(JSON.parse(rendered))
65
+ params[:family] = name
66
+ params[:tags] = [MANAGED_TAG]
67
+ params
68
+ end
69
+ end
70
+
71
+ def digest
72
+ Digest::SHA256.hexdigest(JSON.generate(desired_params))
73
+ end
74
+
75
+ def digest_key
76
+ :"taskdef_#{suffix}_digest"
77
+ end
78
+
79
+ def current
80
+ clients.ecs.describe_task_definition(task_definition: name).task_definition
81
+ rescue Aws::ECS::Errors::ClientException, Aws::ECS::Errors::ClientError
82
+ nil
83
+ end
84
+
85
+ # AWS-side normalization makes a field-by-field diff noisy, so
86
+ # compare against the digest of the params we registered last time.
87
+ def changed?(_current)
88
+ state.resources[digest_key] != digest
89
+ end
90
+
91
+ def create!
92
+ register!
93
+ end
94
+
95
+ def update!(_current)
96
+ register!
97
+ end
98
+
99
+ def register!
100
+ res = clients.ecs.register_task_definition(**desired_params)
101
+ td = res.task_definition
102
+ {
103
+ :"taskdef_#{suffix}" => name,
104
+ :"taskdef_#{suffix}_arn" => td.task_definition_arn,
105
+ digest_key => digest,
106
+ }
107
+ end
108
+
109
+ def outputs(current)
110
+ {
111
+ :"taskdef_#{suffix}" => name,
112
+ :"taskdef_#{suffix}_arn" => current.task_definition_arn,
113
+ digest_key => digest,
114
+ }
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end