seira 0.3.3 → 0.3.6

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.
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