seira 0.3.3 → 0.3.6

Sign up to get free protection for your applications and to get access to all the features.
data/.rubocop.yml CHANGED
@@ -53,7 +53,10 @@ Style/SignalException:
53
53
  Style/StringLiterals:
54
54
  Enabled: false
55
55
 
56
- Style/TrailingCommaInLiteral:
56
+ Style/TrailingCommaInArrayLiteral:
57
+ Enabled: false
58
+
59
+ Style/TrailingCommaInHashLiteral:
57
60
  Enabled: false
58
61
 
59
62
  Style/TrailingCommaInArguments:
data/lib/helpers.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  module Seira
2
2
  class Helpers
3
+ include Seira::Commands
4
+
3
5
  class << self
4
6
  def rails_env(context:)
5
7
  if context[:cluster] == 'internal'
@@ -11,7 +13,21 @@ module Seira
11
13
 
12
14
  def fetch_pods(filters:, app:)
13
15
  filter_string = { app: app }.merge(filters).map { |k, v| "#{k}=#{v}" }.join(',')
14
- JSON.parse(`kubectl get pods --namespace=#{app} -o json --selector=#{filter_string}`)['items']
16
+ output = Seira::Commands.kubectl("get pods -o json --selector=#{filter_string}", context: { app: app }, return_output: true)
17
+ JSON.parse(output)['items']
18
+ end
19
+
20
+ def log_link(context:, app:, query:)
21
+ link = context[:settings].log_link_format
22
+ return nil if link.nil?
23
+ link.gsub! 'APP', app
24
+ link.gsub! 'CLUSTER', context[:cluster]
25
+ link.gsub! 'QUERY', query
26
+ link
27
+ end
28
+
29
+ def get_secret(app:, key:, context: {})
30
+ Secrets.new(app: app, action: 'get', args: [], context: context).get(key)
15
31
  end
16
32
  end
17
33
  end
data/lib/seira.rb CHANGED
@@ -3,6 +3,8 @@ require 'highline/import'
3
3
  require 'colorize'
4
4
  require 'tmpdir'
5
5
 
6
+ require 'seira/commands'
7
+
6
8
  require "seira/version"
7
9
  require 'helpers'
8
10
  require 'seira/app'
@@ -23,6 +25,8 @@ require 'seira/node_pools'
23
25
  # work for the command to a class in lib/seira folder.
24
26
  module Seira
25
27
  class Runner
28
+ include Seira::Commands
29
+
26
30
  CATEGORIES = {
27
31
  'secrets' => Seira::Secrets,
28
32
  'pods' => Seira::Pods,
@@ -128,7 +132,10 @@ module Seira
128
132
  cluster: cluster,
129
133
  project: project,
130
134
  settings: settings,
131
- default_zone: settings.default_zone
135
+ default_zone: settings.default_zone,
136
+ app: app,
137
+ action: action,
138
+ args: args
132
139
  }
133
140
  end
134
141
 
data/lib/seira/app.rb CHANGED
@@ -6,6 +6,8 @@ require 'fileutils'
6
6
  # seira staging specs app bootstrap
7
7
  module Seira
8
8
  class App
9
+ include Seira::Commands
10
+
9
11
  VALID_ACTIONS = %w[help bootstrap apply restart scale revision].freeze
10
12
  SUMMARY = "Bootstrap, scale, configure, restart, your apps.".freeze
11
13
 
@@ -53,7 +55,7 @@ module Seira
53
55
 
54
56
  def ask_cluster_for_current_revision
55
57
  tier = context[:settings].config_for_app(app)['golden_tier'] || 'web'
56
- current_image = `kubectl get deployment --namespace=#{app} -l app=#{app},tier=#{tier} -o=jsonpath='{$.items[:1].spec.template.spec.containers[:1].image}'`.strip.chomp
58
+ current_image = kubectl("get deployment -l app=#{app},tier=#{tier} -o=jsonpath='{$.items[:1].spec.template.spec.containers[:1].image}'", context: context, return_output: true).strip.chomp
57
59
  current_revision = current_image.split(':').last
58
60
  current_revision
59
61
  end
@@ -63,7 +65,8 @@ module Seira
63
65
  def run_bootstrap
64
66
  # TODO: Verify that 00-namespace exists
65
67
  # TODO: Do conformance test on the yaml files before running anything, including that 00-namespace.yaml exists and has right name
66
- system("kubectl apply -f kubernetes/#{context[:cluster]}/#{app}/00-namespace.yaml") # Create namespace before anything else
68
+ # Create namespace before anything else
69
+ kubectl("apply -f kubernetes/#{context[:cluster]}/#{app}/00-namespace.yaml", context: context)
67
70
  bootstrap_main_secret
68
71
  bootstrap_cloudsql_secret
69
72
  bootstrap_gcr_secret
@@ -113,8 +116,7 @@ module Seira
113
116
  replacement_hash: replacement_hash
114
117
  )
115
118
 
116
- puts "Running 'kubectl apply -f #{destination}'"
117
- system("kubectl apply -f #{destination}")
119
+ kubectl("apply -f #{destination}", context: context)
118
120
 
119
121
  unless async
120
122
  puts "Monitoring rollout status..."
@@ -144,7 +146,7 @@ module Seira
144
146
  end
145
147
  replicas = config['spec']['replicas'] if replicas == 'default'
146
148
  puts "scaling #{tier} to #{replicas}"
147
- system("kubectl scale --namespace=#{app} --replicas=#{replicas} deployments/#{config['metadata']['name']}")
149
+ kubectl("scale --replicas=#{replicas} deployments/#{config['metadata']['name']}", context: context)
148
150
  end
149
151
  end
150
152
 
@@ -159,7 +161,7 @@ module Seira
159
161
  # 'internal' is a unique cluster/project "cluster". It always means production in terms of rails app.
160
162
  rails_env = Helpers.rails_env(context: context)
161
163
 
162
- puts `kubectl create secret generic #{main_secret_name} --namespace #{app} --from-literal=RAILS_ENV=#{rails_env} --from-literal=RACK_ENV=#{rails_env}`
164
+ kubectl("create secret generic #{main_secret_name} --from-literal=RAILS_ENV=#{rails_env} --from-literal=RACK_ENV=#{rails_env}", context: context)
163
165
  end
164
166
 
165
167
  # We use a secret in our container to use a service account to connect to our cloudsql databases. The secret in 'default'
data/lib/seira/cluster.rb CHANGED
@@ -5,6 +5,8 @@ require 'fileutils'
5
5
  # Example usages:
6
6
  module Seira
7
7
  class Cluster
8
+ include Seira::Commands
9
+
8
10
  VALID_ACTIONS = %w[help bootstrap upgrade-master].freeze
9
11
  SUMMARY = "For managing whole clusters.".freeze
10
12
 
@@ -49,12 +51,16 @@ module Seira
49
51
  end
50
52
 
51
53
  def self.current_cluster
52
- `kubectl config current-context`.chomp.strip
54
+ Seira::Commands.kubectl("config current-context", context: :none, return_output: true).chomp.strip
55
+ end
56
+
57
+ def self.current_project
58
+ Seira::Commands.gcloud("config get-value project", context: :none, return_output: true).chomp.strip
53
59
  end
54
60
 
55
61
  def current
56
- puts `gcloud config get-value project`
57
- puts `kubectl config current-context`
62
+ puts current_project
63
+ puts current_cluster
58
64
  end
59
65
 
60
66
  private
@@ -76,7 +82,6 @@ module Seira
76
82
  exit(1)
77
83
  end
78
84
 
79
- # puts `kubectl create secret generic gcr-secret --namespace default --from-file=.dockercfg=#{dockercfg_location}`
80
85
  puts `kubectl create secret docker-registry gcr-secret --docker-username=_json_key --docker-password="$(cat #{dockercfg_location})" --docker-server=https://gcr.io --docker-email=doesnotmatter@example.com`
81
86
  puts `kubectl create secret generic cloudsql-credentials --namespace default --from-file=credentials.json=#{cloudsql_credentials_location}`
82
87
  end
@@ -92,7 +97,7 @@ module Seira
92
97
  end
93
98
 
94
99
  # Ensure the specified version is supported by GKE
95
- server_config = JSON.parse(`gcloud container get-server-config --format json`)
100
+ server_config = gcloud("container get-server-config", format: :json, context: context)
96
101
  valid_versions = server_config['validMasterVersions']
97
102
  unless valid_versions.include? new_version
98
103
  puts "Version #{new_version} is unsupported. Supported versions are:"
@@ -100,7 +105,7 @@ module Seira
100
105
  exit(1)
101
106
  end
102
107
 
103
- cluster_config = JSON.parse(`gcloud container clusters describe #{cluster} --format json`)
108
+ cluster_config = JSON.parse(gcloud("container clusters describe #{cluster}", format: :json, context: context))
104
109
 
105
110
  # Update the master node first
106
111
  exit(1) unless Highline.agree("Are you sure you want to upgrade cluster #{cluster} master to version #{new_version}? Services should continue to run fine, but the cluster control plane will be offline.")
@@ -109,7 +114,7 @@ module Seira
109
114
  if cluster_config['currentMasterVersion'] == new_version
110
115
  # Master has already been updated; this step is not needed
111
116
  puts 'Already up to date!'
112
- elsif system("gcloud container clusters upgrade #{cluster} --cluster-version=#{new_version} --master")
117
+ elsif gcloud("container clusters upgrade #{cluster} --cluster-version=#{new_version} --master", format: :boolean, context: context)
113
118
  puts 'Master updated successfully!'
114
119
  else
115
120
  puts 'Failed to update master.'
@@ -0,0 +1,22 @@
1
+ require 'seira/commands/kubectl'
2
+ require 'seira/commands/gcloud'
3
+
4
+ module Seira
5
+ module Commands
6
+ def kubectl(command, context:, clean_output: false, return_output: false)
7
+ Seira::Commands.kubectl(command, context: context, clean_output: clean_output, return_output: return_output)
8
+ end
9
+
10
+ def self.kubectl(command, context:, clean_output: false, return_output: false)
11
+ Kubectl.new(command, context: context).invoke(clean_output: clean_output, return_output: return_output)
12
+ end
13
+
14
+ def gcloud(command, context:, clean_output: false, format: :boolean)
15
+ Seira::Commands.gcloud(command, context: context, clean_output: clean_output, format: format)
16
+ end
17
+
18
+ def self.gcloud(command, context:, clean_output: false, format: :boolean)
19
+ Gcloud.new(command, context: context, clean_output: clean_output, format: format).invoke
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,43 @@
1
+ module Seira
2
+ module Commands
3
+ class Gcloud
4
+ attr_reader :context, :command, :format, :clean_output
5
+
6
+ def initialize(command, context:, clean_output:, format:)
7
+ @command = command
8
+ @context = context
9
+ @format = format
10
+ @clean_output = clean_output
11
+ end
12
+
13
+ def invoke
14
+ puts "Calling: #{calculated_command.green}" unless clean_output
15
+
16
+ if format == :boolean
17
+ system(calculated_command)
18
+ elsif format == :json
19
+ `#{calculated_command}`
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def calculated_command
26
+ @_calculated_command ||= begin
27
+ rv =
28
+ if format == :json
29
+ "gcloud #{command} --format=json"
30
+ else
31
+ "gcloud #{command}"
32
+ end
33
+
34
+ unless context.nil?
35
+ rv = "#{rv} --project=#{context[:project]}"
36
+ end
37
+
38
+ rv
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,34 @@
1
+ module Seira
2
+ module Commands
3
+ class Kubectl
4
+ attr_reader :context, :command
5
+
6
+ def initialize(command, context:)
7
+ @command = command
8
+ @context = context
9
+ end
10
+
11
+ def invoke(clean_output: false, return_output: false)
12
+ puts "Calling: #{calculated_command.green}" unless clean_output
13
+
14
+ if return_output
15
+ `#{calculated_command}`
16
+ else
17
+ system(calculated_command)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def calculated_command
24
+ @_calculated_command ||= begin
25
+ if context == :none
26
+ "kubectl #{command}"
27
+ else
28
+ "kubectl #{command} --namespace=#{context[:app]}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/seira/db.rb CHANGED
@@ -4,7 +4,9 @@ require_relative 'db/create'
4
4
 
5
5
  module Seira
6
6
  class Db
7
- VALID_ACTIONS = %w[help create delete list restart connect ps kill analyze].freeze
7
+ include Seira::Commands
8
+
9
+ VALID_ACTIONS = %w[help create delete list restart connect ps kill analyze create-readonly-user].freeze
8
10
  SUMMARY = "Manage your Cloud SQL Postgres databases.".freeze
9
11
 
10
12
  attr_reader :app, :action, :args, :context
@@ -36,6 +38,8 @@ module Seira
36
38
  run_kill
37
39
  when 'analyze'
38
40
  run_analyze
41
+ when 'create-readonly-user'
42
+ run_create_readonly_user
39
43
  else
40
44
  fail "Unknown command encountered"
41
45
  end
@@ -43,7 +47,7 @@ module Seira
43
47
 
44
48
  # NOTE: Relies on the pgbouncer instance being named based on the db name, as is done in create command
45
49
  def primary_instance
46
- database_url = Secrets.new(app: app, action: 'get', args: [], context: context).get('DATABASE_URL')
50
+ database_url = Helpers.get_secret(app: app, key: 'DATABASE_URL')
47
51
  return nil unless database_url
48
52
 
49
53
  primary_uri = URI.parse(database_url)
@@ -66,6 +70,7 @@ module Seira
66
70
  puts "ps: List running queries"
67
71
  puts "kill: Kill a query"
68
72
  puts "analyze: Display database performance information"
73
+ puts "create-readonly-user: Create a database user named by --username=<name> with only SELECT access privileges"
69
74
  end
70
75
 
71
76
  def run_create
@@ -74,7 +79,7 @@ module Seira
74
79
 
75
80
  def run_delete
76
81
  name = "#{app}-#{args[0]}"
77
- if system("gcloud sql instances delete #{name}")
82
+ if gcloud("sql instances delete #{name}", context: context, format: :boolean)
78
83
  puts "Successfully deleted sql instance #{name}"
79
84
 
80
85
  # TODO: Automate the below
@@ -90,7 +95,7 @@ module Seira
90
95
 
91
96
  def run_restart
92
97
  name = "#{app}-#{args[0]}"
93
- if system("gcloud sql instances restart #{name}")
98
+ if gcloud("sql instances restart #{name}", context: context, format: :boolean)
94
99
  puts "Successfully restarted sql instance #{name}"
95
100
  else
96
101
  puts "Failed to restart sql instance #{name}"
@@ -100,7 +105,7 @@ module Seira
100
105
  def run_connect
101
106
  name = args[0] || primary_instance
102
107
  puts "Connecting to #{name}..."
103
- root_password = Secrets.new(app: app, action: 'get', args: [], context: context).get("#{name.tr('-', '_').upcase}_ROOT_PASSWORD") || "Not found in secrets"
108
+ root_password = Helpers.get_secret(app: app, key: "#{name.tr('-', '_').upcase}_ROOT_PASSWORD") || "Not found in secrets"
104
109
  puts "Your root password for 'postgres' user is: #{root_password}"
105
110
  system("gcloud sql connect #{name}")
106
111
  end
@@ -176,20 +181,88 @@ module Seira
176
181
  )
177
182
  end
178
183
 
179
- def execute_db_command(sql_command)
184
+ # Example: seira staging app-name db create-readonly-user --username=readonlyuser
185
+ def run_create_readonly_user
186
+ instance_name = primary_instance # Always make user changes to primary instance, and they will propogate to replicas
187
+ user_name = nil
188
+
189
+ args.each do |arg|
190
+ if arg.start_with? '--username='
191
+ user_name = arg.split('=')[1]
192
+ else
193
+ puts "Warning: Unrecognized argument '#{arg}'"
194
+ end
195
+ end
196
+
197
+ if user_name.nil? || user_name.strip.chomp == ''
198
+ puts "Please specify the name of the read-only user to create, such as --username=testuser"
199
+ exit(1)
200
+ end
201
+
202
+ # Require that the name be alpha only for simplicity and strict but basic validation
203
+ if user_name.match(/\A[a-zA-Z]*\z/).nil?
204
+ puts "Username must be characters only"
205
+ exit(1)
206
+ end
207
+
208
+ valid_instance_names = existing_instances(remove_app_prefix: false).join(', ')
209
+ if instance_name.nil? || instance_name.strip.chomp == '' || !valid_instance_names.include?(instance_name)
210
+ puts "Could not find a valid instance name - does the DATABASE_URL have a value? Must be one of: #{valid_instance_names}"
211
+ exit(1)
212
+ end
213
+
214
+ password = SecureRandom.urlsafe_base64(32)
215
+ if gcloud("sql users create #{user_name} '' --instance=#{instance_name} --password=#{password}", context: context, format: :boolean)
216
+ puts "Created user '#{user_name}' with password #{password}"
217
+ else
218
+ puts "Failed to create user '#{user_name}'"
219
+ exit(1)
220
+ end
221
+
222
+ puts 'Setting permissions...'
223
+ admin_commands =
224
+ <<~SQL
225
+ REVOKE cloudsqlsuperuser FROM #{user_name};
226
+ ALTER ROLE #{user_name} NOCREATEDB NOCREATEROLE;
227
+ SQL
228
+ database_commands =
229
+ <<~SQL
230
+ REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM #{user_name};
231
+ GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{user_name};
232
+ ALTER DEFAULT PRIVILEGES IN SCHEMA "public" GRANT SELECT ON TABLES TO #{user_name};
233
+ SQL
234
+ execute_db_command(admin_commands, as_admin: true)
235
+ execute_db_command(database_commands)
236
+ end
237
+
238
+ def execute_db_command(sql_command, as_admin: false)
180
239
  # TODO(josh): move pgbouncer naming logic here and in Create to a common location
181
- tier = primary_instance.gsub("#{app}-", '')
240
+ instance_name = primary_instance
241
+ tier = instance_name.gsub("#{app}-", '')
182
242
  matching_pods = Helpers.fetch_pods(app: app, filters: { tier: tier })
183
243
  if matching_pods.empty?
184
244
  puts 'Could not find pgbouncer pod to connect to'
185
245
  exit 1
186
246
  end
187
247
  pod_name = matching_pods.first['metadata']['name']
188
- exit 1 unless system("kubectl exec #{pod_name} --namespace #{app} -- psql -c \"#{sql_command}\"")
248
+ psql_command =
249
+ if as_admin
250
+ root_password = Helpers.get_secret(app: app, key: "#{instance_name.tr('-', '_').upcase}_ROOT_PASSWORD")
251
+ "psql postgres://postgres:#{root_password}@127.0.0.1:5432"
252
+ else
253
+ 'psql'
254
+ end
255
+ exit 1 unless system("kubectl exec #{pod_name} --namespace #{app} -- #{psql_command} -c \"#{sql_command}\"")
189
256
  end
190
257
 
191
- def existing_instances
192
- `gcloud sql instances list --uri`.split("\n").map { |uri| uri.split('/').last }.select { |name| name.start_with? "#{app}-" }.map { |name| name.gsub(/^#{app}-/, '') }
258
+ def existing_instances(remove_app_prefix: true)
259
+ plain_list = `gcloud sql instances list --uri`.split("\n").map { |uri| uri.split('/').last }.select { |name| name.start_with? "#{app}-" }
260
+
261
+ if remove_app_prefix
262
+ plain_list.map { |name| name.gsub(/^#{app}-/, '') }
263
+ else
264
+ plain_list
265
+ end
193
266
  end
194
267
  end
195
268
  end