putpaws 0.0.9 → 0.1.3

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 +165 -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 +121 -0
  9. data/lib/putpaws/ecs/task_command.rb +63 -9
  10. data/lib/putpaws/ecs/tasks/ecs_task.rake +117 -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 +36 -0
  22. data/lib/putpaws/provision/presets/rails-nginx/templates/taskdef-app.json.erb +33 -0
  23. data/lib/putpaws/provision/presets/rails-nginx/templates/taskdef-web.json.erb +52 -0
  24. data/lib/putpaws/provision/provision_config.rb +170 -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 +107 -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 +74 -0
  33. data/lib/putpaws/provision/resources/task_definition.rb +121 -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 +63 -6
@@ -0,0 +1,107 @@
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
+ # Derived from cpu_architecture; devops.environment_type / environment_image
9
+ # override the derivation (useful for staged arch migration).
10
+ ENVIRONMENT_TYPES = {
11
+ 'ARM64' => 'ARM_CONTAINER',
12
+ 'X86_64' => 'LINUX_CONTAINER',
13
+ }
14
+ DEFAULT_IMAGES = {
15
+ 'ARM64' => 'aws/codebuild/amazonlinux-aarch64-standard:3.0',
16
+ 'X86_64' => 'aws/codebuild/standard:7.0',
17
+ }
18
+
19
+ def name
20
+ config.build_project_name
21
+ end
22
+
23
+ def devops
24
+ config.devops_settings
25
+ end
26
+
27
+ def environment_type
28
+ devops[:environment_type] || ENVIRONMENT_TYPES.fetch(config.cpu_architecture)
29
+ end
30
+
31
+ def environment_image
32
+ devops[:environment_image] ||
33
+ (devops[:environment_images] || {})[config.cpu_architecture.to_sym] ||
34
+ DEFAULT_IMAGES.fetch(config.cpu_architecture)
35
+ end
36
+
37
+ def security_group_ids
38
+ ids = state.resources[:security_group_ids]
39
+ raise "security_group_ids not found in state. Run the service step first." if ids.nil? || ids.empty?
40
+ ids
41
+ end
42
+
43
+ def desired_params
44
+ {
45
+ name: name,
46
+ description: "Managed by putpaws for #{config.service_name}",
47
+ source: {
48
+ type: devops[:source_type] || 'GITHUB',
49
+ location: devops[:source_repository],
50
+ buildspec: devops[:buildspec] || 'buildspec.yml',
51
+ },
52
+ artifacts: {type: 'NO_ARTIFACTS'},
53
+ environment: {
54
+ type: environment_type,
55
+ image: environment_image,
56
+ compute_type: devops[:compute_type] || 'BUILD_GENERAL1_SMALL',
57
+ privileged_mode: devops.fetch(:privileged_mode, true),
58
+ },
59
+ service_role: config.roles[:codebuild_role_arn],
60
+ logs_config: {
61
+ cloud_watch_logs: {status: 'ENABLED', group_name: config.build_log_group}
62
+ },
63
+ # CI needs VPC access (Ex: db:migrate against RDS). Reuse the
64
+ # service security group so the build reaches whatever the app can.
65
+ vpc_config: {
66
+ vpc_id: config.base[:vpc_id],
67
+ subnets: config.base[:subnets],
68
+ security_group_ids: security_group_ids,
69
+ },
70
+ tags: [MANAGED_TAG],
71
+ }
72
+ end
73
+
74
+ def current
75
+ res = clients.codebuild.batch_get_projects(names: [name])
76
+ res.projects.first
77
+ end
78
+
79
+ def changed?(current)
80
+ current.source&.type != desired_params[:source][:type] ||
81
+ current.source&.location != desired_params[:source][:location] ||
82
+ current.source&.buildspec != desired_params[:source][:buildspec] ||
83
+ current.service_role != desired_params[:service_role] ||
84
+ current.environment&.type != desired_params[:environment][:type] ||
85
+ current.environment&.image != desired_params[:environment][:image] ||
86
+ current.environment&.compute_type != desired_params[:environment][:compute_type] ||
87
+ current.vpc_config&.subnets.to_a.sort != desired_params[:vpc_config][:subnets].sort ||
88
+ current.vpc_config&.security_group_ids.to_a.sort != desired_params[:vpc_config][:security_group_ids].sort
89
+ end
90
+
91
+ def create!
92
+ res = clients.codebuild.create_project(**desired_params)
93
+ {codebuild_project: res.project.name}
94
+ end
95
+
96
+ def update!(_current)
97
+ res = clients.codebuild.update_project(**desired_params)
98
+ {codebuild_project: res.project.name}
99
+ end
100
+
101
+ def outputs(current)
102
+ {codebuild_project: current.name}
103
+ end
104
+ end
105
+ end
106
+ end
107
+ 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::NoSuchEntity, 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,74 @@
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
+ rescue Aws::ECS::Errors::ClusterNotFoundException
29
+ # cluster not created yet (Ex: `ahead` on a fresh setup) -> no service either
30
+ nil
31
+ end
32
+
33
+ def changed?(current)
34
+ current_family = current.task_definition.to_s.split('/').last.to_s.split(':').first
35
+ current_family != task_definition_family || current.desired_count != desired_count
36
+ end
37
+
38
+ def create!
39
+ res = clients.ecs.create_service(
40
+ cluster: config.cluster_name,
41
+ service_name: name,
42
+ task_definition: task_definition_family,
43
+ desired_count: desired_count,
44
+ launch_type: 'FARGATE',
45
+ enable_execute_command: true,
46
+ network_configuration: {
47
+ awsvpc_configuration: {
48
+ subnets: config.base[:subnets],
49
+ security_groups: security_group_ids,
50
+ assign_public_ip: config.settings[:assign_public_ip] || 'DISABLED',
51
+ }
52
+ },
53
+ tags: [MANAGED_TAG]
54
+ )
55
+ {service_arn: res.service.service_arn, service_name: name}
56
+ end
57
+
58
+ def update!(_current)
59
+ res = clients.ecs.update_service(
60
+ cluster: config.cluster_name,
61
+ service: name,
62
+ task_definition: task_definition_family,
63
+ desired_count: desired_count
64
+ )
65
+ {service_arn: res.service.service_arn, service_name: name}
66
+ end
67
+
68
+ def outputs(current)
69
+ {service_arn: current.service_arn, service_name: name}
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,121 @@
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, :cpu_architecture,
15
+ :image, :nginx_image,
16
+ :log_group, :execution_role_arn, :task_role_arn,
17
+ :env_json, :secrets_json
18
+
19
+ def initialize(config)
20
+ settings = config.settings
21
+ @service_name = config.service_name
22
+ @region = config.region
23
+ @account_id = config.account_id
24
+ @cpu = settings[:cpu].to_s
25
+ @memory = settings[:memory].to_s
26
+ @container_port = (settings[:container_port] || 3000).to_i
27
+ @cpu_architecture = config.cpu_architecture
28
+ @image = config.ecr_image_uri
29
+ @nginx_image = settings[:nginx_image] || 'public.ecr.aws/nginx/nginx:stable'
30
+ @log_group = config.log_group
31
+ @execution_role_arn = config.roles[:task_execution_role_arn]
32
+ @task_role_arn = config.roles[:task_role_arn]
33
+ @env_json = JSON.generate(
34
+ (settings[:env] || {}).map{|k, v| {name: k.to_s, value: v.to_s}}
35
+ )
36
+ @secrets_json = JSON.generate(
37
+ config.resolved_secrets.map{|name, path|
38
+ {name: name, valueFrom: "arn:aws:ssm:#{config.region}:#{config.account_id}:parameter#{path}"}
39
+ }
40
+ )
41
+ end
42
+
43
+ def get_binding
44
+ binding
45
+ end
46
+ end
47
+
48
+ attr_reader :entry
49
+ def initialize(entry:, **args)
50
+ super(**args)
51
+ @entry = entry
52
+ end
53
+
54
+ def suffix
55
+ entry[:suffix]
56
+ end
57
+
58
+ def name
59
+ config.task_definition_family(suffix)
60
+ end
61
+
62
+ def desired_params
63
+ @desired_params ||= begin
64
+ template = File.read(config.preset.template_path(entry[:template]))
65
+ rendered = ERB.new(template, trim_mode: '-').result(TemplateContext.new(config).get_binding)
66
+ params = Util.deep_underscore_keys(JSON.parse(rendered))
67
+ params[:family] = name
68
+ params[:tags] = [MANAGED_TAG]
69
+ params
70
+ end
71
+ end
72
+
73
+ def digest
74
+ Digest::SHA256.hexdigest(JSON.generate(desired_params))
75
+ end
76
+
77
+ def digest_key
78
+ :"taskdef_#{suffix}_digest"
79
+ end
80
+
81
+ def current
82
+ clients.ecs.describe_task_definition(task_definition: name).task_definition
83
+ rescue Aws::ECS::Errors::ClientException, Aws::ECS::Errors::ClientError
84
+ nil
85
+ end
86
+
87
+ # AWS-side normalization makes a field-by-field diff noisy, so
88
+ # compare against the digest of the params we registered last time.
89
+ def changed?(_current)
90
+ state.resources[digest_key] != digest
91
+ end
92
+
93
+ def create!
94
+ register!
95
+ end
96
+
97
+ def update!(_current)
98
+ register!
99
+ end
100
+
101
+ def register!
102
+ res = clients.ecs.register_task_definition(**desired_params)
103
+ td = res.task_definition
104
+ {
105
+ :"taskdef_#{suffix}" => name,
106
+ :"taskdef_#{suffix}_arn" => td.task_definition_arn,
107
+ digest_key => digest,
108
+ }
109
+ end
110
+
111
+ def outputs(current)
112
+ {
113
+ :"taskdef_#{suffix}" => name,
114
+ :"taskdef_#{suffix}_arn" => current.task_definition_arn,
115
+ digest_key => digest,
116
+ }
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end