seira 0.3.3 → 0.3.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.default-rubocop.yml +216 -61
- data/.rubocop.yml +4 -1
- data/lib/helpers.rb +17 -1
- data/lib/seira.rb +8 -1
- data/lib/seira/app.rb +8 -6
- data/lib/seira/cluster.rb +12 -7
- data/lib/seira/commands.rb +22 -0
- data/lib/seira/commands/gcloud.rb +43 -0
- data/lib/seira/commands/kubectl.rb +34 -0
- data/lib/seira/db.rb +83 -10
- data/lib/seira/db/create.rb +10 -9
- data/lib/seira/jobs.rb +9 -6
- data/lib/seira/node_pools.rb +10 -8
- data/lib/seira/pods.rb +6 -4
- data/lib/seira/proxy.rb +3 -1
- data/lib/seira/secrets.rb +6 -4
- data/lib/seira/settings.rb +5 -1
- data/lib/seira/version.rb +1 -1
- data/seira.gemspec +1 -1
- metadata +7 -4
data/.rubocop.yml
CHANGED
@@ -53,7 +53,10 @@ Style/SignalException:
|
|
53
53
|
Style/StringLiterals:
|
54
54
|
Enabled: false
|
55
55
|
|
56
|
-
Style/
|
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
|
-
|
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 =
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
57
|
-
puts
|
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 =
|
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(
|
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
|
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
|
-
|
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 =
|
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
|
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
|
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 =
|
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
|
-
|
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
|
-
|
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
|
-
|
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}-" }
|
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
|