simplygenius-atmos 0.7.1 → 0.8.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -4
  3. data/exe/atmos +2 -2
  4. data/lib/{atmos.rb → simplygenius/atmos.rb} +9 -7
  5. data/lib/simplygenius/atmos/cli.rb +116 -0
  6. data/lib/simplygenius/atmos/commands/account.rb +69 -0
  7. data/lib/simplygenius/atmos/commands/apply.rb +24 -0
  8. data/lib/simplygenius/atmos/commands/auth_exec.rb +34 -0
  9. data/lib/simplygenius/atmos/commands/base_command.rb +16 -0
  10. data/lib/simplygenius/atmos/commands/bootstrap.rb +76 -0
  11. data/lib/simplygenius/atmos/commands/container.rb +62 -0
  12. data/lib/simplygenius/atmos/commands/destroy.rb +22 -0
  13. data/lib/simplygenius/atmos/commands/generate.rb +187 -0
  14. data/lib/simplygenius/atmos/commands/init.rb +22 -0
  15. data/lib/simplygenius/atmos/commands/new.rb +22 -0
  16. data/lib/simplygenius/atmos/commands/otp.rb +58 -0
  17. data/lib/simplygenius/atmos/commands/plan.rb +24 -0
  18. data/lib/simplygenius/atmos/commands/secret.rb +91 -0
  19. data/lib/simplygenius/atmos/commands/terraform.rb +56 -0
  20. data/lib/simplygenius/atmos/commands/user.rb +78 -0
  21. data/lib/simplygenius/atmos/config.rb +279 -0
  22. data/lib/simplygenius/atmos/exceptions.rb +13 -0
  23. data/lib/simplygenius/atmos/generator.rb +232 -0
  24. data/lib/simplygenius/atmos/ipc.rb +136 -0
  25. data/lib/simplygenius/atmos/ipc_actions/notify.rb +31 -0
  26. data/lib/simplygenius/atmos/ipc_actions/ping.rb +23 -0
  27. data/lib/simplygenius/atmos/logging.rb +164 -0
  28. data/lib/simplygenius/atmos/otp.rb +62 -0
  29. data/lib/simplygenius/atmos/plugin.rb +27 -0
  30. data/lib/simplygenius/atmos/plugin_manager.rb +120 -0
  31. data/lib/simplygenius/atmos/plugins/output_filter.rb +29 -0
  32. data/lib/simplygenius/atmos/plugins/prompt_notify.rb +21 -0
  33. data/lib/simplygenius/atmos/provider_factory.rb +23 -0
  34. data/lib/simplygenius/atmos/providers/aws/account_manager.rb +83 -0
  35. data/lib/simplygenius/atmos/providers/aws/auth_manager.rb +220 -0
  36. data/lib/simplygenius/atmos/providers/aws/container_manager.rb +118 -0
  37. data/lib/simplygenius/atmos/providers/aws/provider.rb +53 -0
  38. data/lib/simplygenius/atmos/providers/aws/s3_secret_manager.rb +51 -0
  39. data/lib/simplygenius/atmos/providers/aws/user_manager.rb +213 -0
  40. data/lib/simplygenius/atmos/settings_hash.rb +93 -0
  41. data/lib/simplygenius/atmos/source_path.rb +186 -0
  42. data/lib/simplygenius/atmos/template.rb +117 -0
  43. data/lib/simplygenius/atmos/terraform_executor.rb +297 -0
  44. data/lib/simplygenius/atmos/ui.rb +173 -0
  45. data/lib/simplygenius/atmos/utils.rb +54 -0
  46. data/lib/simplygenius/atmos/version.rb +5 -0
  47. data/templates/new/config/atmos.yml +21 -13
  48. data/templates/new/config/atmos/recipes.yml +16 -0
  49. data/templates/new/config/atmos/runtime.yml +9 -0
  50. metadata +46 -40
  51. data/lib/atmos/cli.rb +0 -105
  52. data/lib/atmos/commands/account.rb +0 -65
  53. data/lib/atmos/commands/apply.rb +0 -20
  54. data/lib/atmos/commands/auth_exec.rb +0 -29
  55. data/lib/atmos/commands/base_command.rb +0 -12
  56. data/lib/atmos/commands/bootstrap.rb +0 -72
  57. data/lib/atmos/commands/container.rb +0 -58
  58. data/lib/atmos/commands/destroy.rb +0 -18
  59. data/lib/atmos/commands/generate.rb +0 -90
  60. data/lib/atmos/commands/init.rb +0 -18
  61. data/lib/atmos/commands/new.rb +0 -18
  62. data/lib/atmos/commands/otp.rb +0 -54
  63. data/lib/atmos/commands/plan.rb +0 -20
  64. data/lib/atmos/commands/secret.rb +0 -87
  65. data/lib/atmos/commands/terraform.rb +0 -52
  66. data/lib/atmos/commands/user.rb +0 -74
  67. data/lib/atmos/config.rb +0 -208
  68. data/lib/atmos/exceptions.rb +0 -9
  69. data/lib/atmos/generator.rb +0 -199
  70. data/lib/atmos/generator_factory.rb +0 -93
  71. data/lib/atmos/ipc.rb +0 -132
  72. data/lib/atmos/ipc_actions/notify.rb +0 -27
  73. data/lib/atmos/ipc_actions/ping.rb +0 -19
  74. data/lib/atmos/logging.rb +0 -160
  75. data/lib/atmos/otp.rb +0 -61
  76. data/lib/atmos/provider_factory.rb +0 -19
  77. data/lib/atmos/providers/aws/account_manager.rb +0 -82
  78. data/lib/atmos/providers/aws/auth_manager.rb +0 -208
  79. data/lib/atmos/providers/aws/container_manager.rb +0 -116
  80. data/lib/atmos/providers/aws/provider.rb +0 -51
  81. data/lib/atmos/providers/aws/s3_secret_manager.rb +0 -49
  82. data/lib/atmos/providers/aws/user_manager.rb +0 -211
  83. data/lib/atmos/settings_hash.rb +0 -90
  84. data/lib/atmos/terraform_executor.rb +0 -267
  85. data/lib/atmos/ui.rb +0 -159
  86. data/lib/atmos/utils.rb +0 -50
  87. data/lib/atmos/version.rb +0 -3
@@ -0,0 +1,120 @@
1
+ require_relative '../atmos'
2
+ require_relative 'plugin'
3
+ require 'set'
4
+
5
+ module SimplyGenius
6
+ module Atmos
7
+
8
+ class PluginManager
9
+ include GemLogger::LoggerSupport
10
+
11
+ attr_reader :plugins
12
+
13
+ def initialize(plugins)
14
+ @plugins = []
15
+ Array(plugins).each do |plugin|
16
+ if plugin.is_a?(String)
17
+ name = plugin
18
+ plugin = SettingsHash.new
19
+ plugin[:name] = name
20
+ elsif plugin.is_a?(Hash)
21
+ plugin = SettingsHash.new(plugin)
22
+ if plugin[:name].blank?
23
+ logger.error "Invalid plugin definition, :name missing: #{plugin}"
24
+ next
25
+ end
26
+ else
27
+ logger.error "Invalid plugin definition: #{plugin}"
28
+ next
29
+ end
30
+ @plugins << plugin
31
+ end
32
+
33
+ @plugin_classes = Set.new
34
+ @plugin_instances = []
35
+ @output_filters = {}
36
+ end
37
+
38
+ def load_plugins
39
+ @plugins.each do |plugin|
40
+ load_plugin(plugin)
41
+
42
+ # Check for new plugin classes after each plugin load so that we can
43
+ # initialize them with their own config hash
44
+ Plugin.descendants.each do |plugin_class|
45
+ begin
46
+ if ! @plugin_classes.include?(plugin_class)
47
+ @plugin_classes << plugin_class
48
+ @plugin_instances << plugin_class.new(plugin)
49
+ end
50
+ rescue StandardError => e
51
+ logger.log_exception e, "Failed to initialize plugin: #{plugin_class}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ def load_plugin(plugin)
58
+ begin
59
+ name = plugin[:name]
60
+ require_name = plugin[:require] || name.gsub('-', '/')
61
+ logger.debug("Loading plugin #{name} as #{require_name}")
62
+ require require_name
63
+ rescue LoadError, StandardError => e
64
+ logger.log_exception e, "Failed to load atmos plugin: #{name} - #{e.message}"
65
+ end
66
+ end
67
+
68
+ def validate_output_filter_type(type)
69
+ raise "Invalid output filter type #{type}, must be one of [:stdout, :stderr]" unless [:stdout, :stderr].include?(type)
70
+ end
71
+
72
+ def register_output_filter(type, clazz)
73
+ validate_output_filter_type(type)
74
+ @output_filters[type.to_sym] ||= []
75
+ @output_filters[type.to_sym] << clazz
76
+ end
77
+
78
+ def output_filters(type, context)
79
+ validate_output_filter_type(type)
80
+ @output_filters[type.to_sym] ||= []
81
+ return OutputFilterCollection.new(@output_filters[type.to_sym].collect {|clazz| clazz.new(context) })
82
+ end
83
+
84
+ class OutputFilterCollection
85
+ include GemLogger::LoggerSupport
86
+
87
+ attr_accessor :filters
88
+
89
+ def initialize(filters)
90
+ @filters = filters
91
+ end
92
+
93
+ def filter_block
94
+ return Proc.new do |data|
95
+ @filters.inject(data) do |memo, obj|
96
+ begin
97
+ obj.filter(memo)
98
+ rescue StandardError => e
99
+ logger.log_exception e, "Output filter failed during filter: #{obj.class}"
100
+ memo
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ def close
107
+ @filters.each do |f|
108
+ begin
109
+ f.close
110
+ rescue StandardError => e
111
+ logger.log_exception e, "Output filter failed during close: #{f.class}"
112
+ end
113
+ end
114
+ end
115
+
116
+ end
117
+ end
118
+
119
+ end
120
+ end
@@ -0,0 +1,29 @@
1
+ require_relative '../../atmos'
2
+ require_relative '../../atmos/ui'
3
+
4
+ module SimplyGenius
5
+ module Atmos
6
+ module Plugins
7
+
8
+ class OutputFilter
9
+ include GemLogger::LoggerSupport
10
+ include UI
11
+
12
+ attr_reader :context
13
+
14
+ def initialize(context)
15
+ @context = context
16
+ end
17
+
18
+ def filter(data)
19
+ raise "not implemented"
20
+ end
21
+
22
+ def close
23
+ end
24
+
25
+ end
26
+
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ require_relative '../../atmos'
2
+ require_relative 'output_filter'
3
+
4
+ module SimplyGenius
5
+ module Atmos
6
+ module Plugins
7
+
8
+ class PromptNotify < OutputFilter
9
+
10
+ def filter(data)
11
+ if data =~ /^[\e\[\dm\s]*Enter a value:[\e\[\dm\s]*$/
12
+ notify(message: "Terraform is waiting for user input")
13
+ end
14
+ data
15
+ end
16
+
17
+ end
18
+
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ require_relative '../atmos'
2
+
3
+ module SimplyGenius
4
+ module Atmos
5
+
6
+ class ProviderFactory
7
+ include GemLogger::LoggerSupport
8
+
9
+ def self.get(name)
10
+ @provider ||= begin
11
+ logger.debug("Loading provider: #{name}")
12
+ require "simplygenius/atmos/providers/#{name}/provider"
13
+ provider = "SimplyGenius::Atmos::Providers::#{name.camelize}::Provider".constantize
14
+ logger.debug("Loaded provider #{provider}")
15
+ provider.new(name)
16
+ end
17
+ return @provider
18
+ end
19
+
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,83 @@
1
+ require_relative '../../../atmos'
2
+ require 'aws-sdk-organizations'
3
+
4
+ module SimplyGenius
5
+ module Atmos
6
+ module Providers
7
+ module Aws
8
+
9
+ class AccountManager
10
+ include GemLogger::LoggerSupport
11
+ include FileUtils
12
+
13
+ def initialize(provider)
14
+ @provider = provider
15
+ end
16
+
17
+ def create_account(env, name: nil, email: nil)
18
+ result = {}
19
+ org = ::Aws::Organizations::Client.new
20
+ resp = nil
21
+ name ||= "Atmos #{env} account"
22
+
23
+ begin
24
+ logger.info "Looking up organization"
25
+ resp = org.describe_organization()
26
+ logger.debug "Described organization: #{resp.to_h}"
27
+ rescue ::Aws::Organizations::Errors::AWSOrganizationsNotInUseException
28
+ logger.info "Organization doesn't exist, creating"
29
+ resp = org.create_organization()
30
+ logger.debug "Created organization: #{resp.to_h}"
31
+ end
32
+
33
+ if email.blank?
34
+ master_email = resp.organization.master_account_email
35
+ email = master_email.sub('@', "+#{env}@")
36
+ end
37
+ result[:email] = email
38
+ result[:name] = name
39
+
40
+
41
+ begin
42
+ logger.info "Creating account named #{name}"
43
+ resp = org.create_account(account_name: name, email: email)
44
+ rescue ::Aws::Organizations::Errors::FinalizingOrganizationException
45
+ logger.info "Waiting to retry account creation as the organization needs to finalize"
46
+ logger.info "This will eventually succeed after receiving a"
47
+ logger.info "'Consolidated Billing verification' email from AWS"
48
+ logger.info "You can leave this running or cancel and restart later."
49
+ sleep 60
50
+ retry
51
+ end
52
+
53
+ logger.debug "Created account: #{resp.to_h}"
54
+
55
+ status_id = resp.create_account_status.id
56
+ status = resp.create_account_status.state
57
+ account_id = resp.create_account_status.account_id
58
+
59
+ while status =~ /in_progress/i
60
+ logger.info "Waiting for account creation to complete, status: #{status}"
61
+ resp = org.describe_create_account_status(create_account_request_id: status_id)
62
+ logger.debug("Account creation status check: #{resp.to_h}")
63
+ status = resp.create_account_status.state
64
+ account_id = resp.create_account_status.account_id
65
+ sleep 5
66
+ end
67
+
68
+ if status =~ /failed/i
69
+ logger.error "Failed to create account: #{resp.create_account_status.failure_reason}"
70
+ exit(1)
71
+ end
72
+
73
+ result[:account_id] = account_id
74
+
75
+ return result
76
+ end
77
+
78
+ end
79
+
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,220 @@
1
+ require_relative '../../../atmos'
2
+ require_relative '../../../atmos/utils'
3
+ require_relative '../../../atmos/ui'
4
+ require_relative '../../../atmos/otp'
5
+ require 'aws-sdk-core'
6
+ require 'aws-sdk-iam'
7
+ require 'json'
8
+
9
+ module SimplyGenius
10
+ module Atmos
11
+ module Providers
12
+ module Aws
13
+
14
+ class AuthManager
15
+ include GemLogger::LoggerSupport
16
+ include FileUtils
17
+ include UI
18
+
19
+ def initialize(provider)
20
+ @provider = provider
21
+ end
22
+
23
+ def authenticate(system_env, **opts, &block)
24
+
25
+ profile = system_env['AWS_PROFILE']
26
+ key = system_env['AWS_ACCESS_KEY_ID']
27
+ secret = system_env['AWS_SECRET_ACCESS_KEY']
28
+ if profile.blank? && (key.blank? || secret.blank?)
29
+ logger.warn("An aws profile or key/secret should be supplied via the environment")
30
+ end
31
+
32
+ # Handle bootstrapping a new env account. Newly created organization
33
+ # accounts only have the default role that can only be assumed by an
34
+ # iam user, so use that as the target for assume_role, and the root
35
+ # check below will ensure iam user
36
+ assume_role_name = nil
37
+ if opts[:bootstrap] && Atmos.config.atmos_env != 'ops'
38
+ # TODO: do this hack better
39
+ assume_role_name = Atmos.config["auth.bootstrap_assume_role_name"]
40
+ else
41
+ assume_role_name = opts[:role] || Atmos.config["auth.assume_role_name"]
42
+ end
43
+ account_id = Atmos.config.account_hash[Atmos.config.atmos_env].to_s
44
+ role_arn = "arn:aws:iam::#{account_id}:role/#{assume_role_name}"
45
+
46
+ user_name = nil
47
+ begin
48
+ sts = ::Aws::STS::Client.new
49
+ resp = sts.get_caller_identity
50
+ arn_pieces = resp.arn.split(":")
51
+ user_name = arn_pieces.last.split("/").last
52
+
53
+ # root credentials can't assume role, but they should have full
54
+ # access for the current account, so proceed (e.g. for bootstrap).
55
+ if arn_pieces.last == "root"
56
+
57
+ # We check the account of the caller to prevent root user of ops
58
+ # account from bootstrapping an env account, but still allow a
59
+ # root user of the env account itself to be able to bootstrap
60
+ # (i.e. to allow not organizational accounts to bootstrap using
61
+ # their root user)
62
+ if arn_pieces[-2] != account_id
63
+ logger.error <<~EOF
64
+ Account doesn't match credentials. Bootstrapping a new
65
+ account should be done as an iam user from the ops account or
66
+ using credentials for a root user of the env account.
67
+ EOF
68
+ exit(1)
69
+ end
70
+
71
+ # Should only use root credentials for bootstrap, and thus we
72
+ # won't have role requirement for mfa, etc, even if root account
73
+ # uses mfa for login. Thus skip all the other stuff, to
74
+ # encourage/force use of non-root accounts for normal use
75
+ logger.warn("Using aws root credentials - should only be neccessary for bootstrap")
76
+ return block.call(Hash[system_env])
77
+ end
78
+
79
+ rescue ::Aws::STS::Errors::ServiceError => e
80
+ logger.error "Could not discover aws credentials"
81
+ exit(1)
82
+ end
83
+
84
+ auth_needed = true
85
+ cache_key = "#{user_name}-#{assume_role_name}"
86
+ credentials = read_auth_cache[cache_key]
87
+
88
+ if credentials.present?
89
+ logger.debug("Session cache present, checking expiration...")
90
+ expiration = Time.parse(credentials['expiration'])
91
+ session_renew_interval = (session_duration / 4).to_i
92
+
93
+ if Time.now > expiration
94
+ logger.debug "Session cache is expired, performing normal auth"
95
+ auth_needed = true
96
+ elsif Time.now > (expiration - session_renew_interval)
97
+ begin
98
+ # TODO: investigate making all info a warn so we don't pollute stdout for shell scripts
99
+ logger.info "Session approaching expiration, renewing..."
100
+ credentials = assume_role(role_arn, credentials: credentials, user_name: user_name)
101
+ write_auth_cache(cache_key => credentials)
102
+ auth_needed = false
103
+ rescue => e
104
+ logger.info "Failed to renew credentials using session cache, reason: #{e.message}"
105
+ auth_needed = true
106
+ end
107
+ else
108
+ logger.debug "Session cache is current, skipping auth"
109
+ auth_needed = false
110
+ end
111
+ end
112
+
113
+ if auth_needed
114
+ begin
115
+ logger.info "No active session cache, authenticating..."
116
+
117
+ credentials = assume_role(role_arn, user_name: user_name)
118
+ write_auth_cache(cache_key => credentials)
119
+
120
+ rescue ::Aws::STS::Errors::AccessDenied => e
121
+ if e.message !~ /explicit deny/
122
+ logger.debug "Access Denied, reason: #{e.message}"
123
+ end
124
+
125
+ logger.info "Normal auth failed, checking for mfa"
126
+
127
+ iam = ::Aws::IAM::Client.new
128
+ response = iam.list_mfa_devices(user_name: user_name)
129
+ mfa_serial = response.mfa_devices.first.try(:serial_number)
130
+ token = nil
131
+ if mfa_serial.present?
132
+
133
+ token = Otp.instance.generate(user_name)
134
+ if token.nil?
135
+ token = ask("Enter token to retry with mfa: ")
136
+ else
137
+ logger.info "Used integrated atmos mfa to generate token"
138
+ end
139
+
140
+ if token.blank?
141
+ logger.error "A MFA token must be supplied"
142
+ exit(1)
143
+ end
144
+
145
+ else
146
+ logger.error "MFA is not setup for your account, retry after doing so"
147
+ exit(1)
148
+ end
149
+
150
+ credentials = assume_role(role_arn, serial_number: mfa_serial, token_code: token, user_name: user_name)
151
+ write_auth_cache(cache_key => credentials)
152
+
153
+ end
154
+ end
155
+
156
+ process_env = {}
157
+ process_env['AWS_ACCESS_KEY_ID'] = credentials['access_key_id']
158
+ process_env['AWS_SECRET_ACCESS_KEY'] = credentials['secret_access_key']
159
+ process_env['AWS_SESSION_TOKEN'] = credentials['session_token']
160
+ logger.debug("Calling authentication target with env: #{process_env.inspect}")
161
+ block.call(Hash[system_env].merge(process_env))
162
+ end
163
+
164
+ private
165
+
166
+ def session_duration
167
+ @session_duration ||= (Atmos.config["auth.session_duration"] || 3600).to_i
168
+ end
169
+
170
+ def assume_role(role_arn, **opts)
171
+ # use Aws::AssumeRoleCredentials ?
172
+ if opts[:credentials]
173
+ c = opts.delete(:credentials)
174
+ creds = ::Aws::Credentials.new(
175
+ c[:access_key_id], c[:secret_access_key], c[:session_token]
176
+ )
177
+ client_opts = {credentials: creds}
178
+ else
179
+ client_opts = {}
180
+ end
181
+
182
+ user_name = opts.delete(:user_name)
183
+ if user_name
184
+ session_name = "Atmos-#{user_name}"
185
+ else
186
+ session_name = "Atmos"
187
+ end
188
+
189
+ sts = ::Aws::STS::Client.new(client_opts)
190
+ params = {
191
+ duration_seconds: session_duration,
192
+ role_session_name: session_name,
193
+ role_arn: role_arn
194
+ }.merge(opts)
195
+ logger.debug("Assuming role: #{params}")
196
+ resp = sts.assume_role(params)
197
+ return Utils::SymbolizedMash.new(resp.credentials.to_h)
198
+ end
199
+
200
+ def auth_cache_file
201
+ File.join(Atmos.config.auth_cache_dir, 'aws-assume-role.json')
202
+ end
203
+
204
+ def write_auth_cache(h)
205
+ File.open(auth_cache_file, 'w') do |f|
206
+ f.puts(JSON.pretty_generate(h))
207
+ end
208
+ end
209
+
210
+ def read_auth_cache
211
+ data = JSON.parse(File.read(auth_cache_file)) rescue {}
212
+ Utils::SymbolizedMash.new(data)
213
+ end
214
+
215
+ end
216
+
217
+ end
218
+ end
219
+ end
220
+ end