seira 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.default-rubocop.yml +1614 -0
- data/.gitignore +12 -0
- data/.hound.yml +3 -0
- data/.rspec +2 -0
- data/.rubocop.yml +109 -0
- data/.ruby-version +1 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +41 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/seira +5 -0
- data/bin/setup +8 -0
- data/lib/seira.rb +125 -0
- data/lib/seira/app.rb +130 -0
- data/lib/seira/cluster.rb +79 -0
- data/lib/seira/memcached.rb +108 -0
- data/lib/seira/pods.rb +70 -0
- data/lib/seira/proxy.rb +13 -0
- data/lib/seira/random.rb +404 -0
- data/lib/seira/redis.rb +123 -0
- data/lib/seira/secrets.rb +148 -0
- data/lib/seira/settings.rb +59 -0
- data/lib/seira/setup.rb +99 -0
- data/lib/seira/version.rb +3 -0
- data/seira.gemspec +29 -0
- metadata +142 -0
data/lib/seira/redis.rb
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'base64'
|
3
|
+
|
4
|
+
module Seira
|
5
|
+
class Redis
|
6
|
+
VALID_ACTIONS = %w[list status credentials create delete].freeze
|
7
|
+
|
8
|
+
attr_reader :app, :action, :args, :context
|
9
|
+
|
10
|
+
def initialize(app:, action:, args:, context:)
|
11
|
+
@app = app
|
12
|
+
@action = action
|
13
|
+
@args = args
|
14
|
+
@context = context
|
15
|
+
end
|
16
|
+
|
17
|
+
# TODO: logs, upgrades?, backups, restores, CLI connection
|
18
|
+
def run
|
19
|
+
case action
|
20
|
+
when 'list'
|
21
|
+
run_list
|
22
|
+
when 'status'
|
23
|
+
run_status
|
24
|
+
when 'credentials'
|
25
|
+
run_credentials
|
26
|
+
when 'create'
|
27
|
+
run_create
|
28
|
+
when 'delete'
|
29
|
+
run_delete
|
30
|
+
else
|
31
|
+
fail "Unknown command encountered"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def run_list
|
38
|
+
list = `helm list`.split("\n")
|
39
|
+
filtered_list = list.select { |item| item.start_with?("#{app}-redis") }
|
40
|
+
filtered_list.each do |item|
|
41
|
+
puts item
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def run_status
|
46
|
+
puts `helm status #{app}-redis-#{args[0]}`
|
47
|
+
end
|
48
|
+
|
49
|
+
def run_create
|
50
|
+
# Fairly beefy default compute because it's cheap and the longer we can defer upgrading the
|
51
|
+
# better. Go even higher for production apps.
|
52
|
+
# TODO: Enable metrics
|
53
|
+
values = {
|
54
|
+
persistence: {
|
55
|
+
size: '32Gi'
|
56
|
+
},
|
57
|
+
resources: {
|
58
|
+
requests: {
|
59
|
+
cpu: '2', # roughly 2 vCPU in both AWS and GCP terms
|
60
|
+
memory: '8Gi' # redis is in-memory - give it a lot
|
61
|
+
}
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
args.each do |arg|
|
66
|
+
puts "Applying arg #{arg} to values"
|
67
|
+
if arg.start_with?('--memory=')
|
68
|
+
values[:resources][:requests][:memory] = arg.split('=')[1]
|
69
|
+
elsif arg.start_with?('--volume=')
|
70
|
+
values[:persistence][:volume] = arg.split('=')[1]
|
71
|
+
elsif arg.start_with?('--cpu=')
|
72
|
+
values[:resources][:requests][:cpu] = arg.split('=')[1]
|
73
|
+
elsif arg.start_with?('--size=')
|
74
|
+
size = arg.split('=')[1]
|
75
|
+
case size
|
76
|
+
when '1'
|
77
|
+
values[:resources][:requests][:memory] = '100Mi' # 100mb
|
78
|
+
values[:persistence][:size] = '5Gi'
|
79
|
+
values[:resources][:requests][:cpu] = '100m' # .1 cpu
|
80
|
+
else
|
81
|
+
fail "There is no size option '#{size}'"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
file_name = write_config(values)
|
87
|
+
unique_name = "#{Seira::Random.color}-#{Seira::Random.animal}"
|
88
|
+
name = "#{app}-#{unique_name}"
|
89
|
+
puts `helm install --namespace #{app} --name #{name} --wait -f #{file_name} stable/redis`
|
90
|
+
|
91
|
+
File.delete(file_name)
|
92
|
+
|
93
|
+
puts "To get status: 'seira #{context[:cluster]} #{app} redis status #{unique_name}'"
|
94
|
+
puts "To get credentials for storing in app secrets: 'seira #{context[:cluster]} #{app} redis credentials #{unique_name}'"
|
95
|
+
puts "Service URI for this Redis instance: 'redis://:<password goes here>@#{name}-redis:6379/0'."
|
96
|
+
end
|
97
|
+
|
98
|
+
def run_delete
|
99
|
+
to_delete = "#{app}-#{args[0]}"
|
100
|
+
|
101
|
+
exit(1) unless HighLine.agree("Are you sure you want to delete #{to_delete}? If any apps are using this redis instance, they will break.")
|
102
|
+
|
103
|
+
if system("helm delete #{to_delete}")
|
104
|
+
puts "Successfully deleted #{to_delete}. Mistake and seeing errors now? You can rollback easily. Below is last 5 revisions of the now deleted resource."
|
105
|
+
history = `helm history --max 5 #{to_delete}`
|
106
|
+
puts history
|
107
|
+
last_revision = history.split("\n").last.split(" ").map(&:strip)[0]
|
108
|
+
puts "helm rollback #{to_delete} #{last_revision}"
|
109
|
+
puts "Docs: https://github.com/kubernetes/helm/blob/master/docs/helm/helm_rollback.md"
|
110
|
+
else
|
111
|
+
puts "Delete failed"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def write_config(values)
|
116
|
+
file_name = "tmp/temp-redis-config-#{Seira::Cluster.current_cluster}-#{app}.json"
|
117
|
+
File.open(file_name, "wb") do |f|
|
118
|
+
f.write(values.to_json)
|
119
|
+
end
|
120
|
+
file_name
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'base64'
|
3
|
+
|
4
|
+
# Example usages:
|
5
|
+
# seira staging specs secret set RAILS_ENV staging
|
6
|
+
# seira demo tracking secret unset DISABLE_SOME_FEATURE
|
7
|
+
# seira staging importer secret list
|
8
|
+
# TODO: Multiple secrets in one command
|
9
|
+
# TODO: Can we avoid writing to disk completely and instead pipe in raw json?
|
10
|
+
module Seira
|
11
|
+
class Secrets
|
12
|
+
VALID_ACTIONS = %w[get set unset list list-decoded create-pgbouncer-secret].freeze
|
13
|
+
PGBOUNCER_SECRETS_NAME = 'pgbouncer-secrets'.freeze
|
14
|
+
|
15
|
+
attr_reader :app, :action, :key, :value, :args, :context
|
16
|
+
|
17
|
+
def initialize(app:, action:, args:, context:)
|
18
|
+
@app = app
|
19
|
+
@action = action
|
20
|
+
@args = args
|
21
|
+
@key = args[0]
|
22
|
+
@value = args[1]
|
23
|
+
@context = context
|
24
|
+
end
|
25
|
+
|
26
|
+
def run
|
27
|
+
case action
|
28
|
+
when 'get'
|
29
|
+
perform_key_validation
|
30
|
+
run_get
|
31
|
+
when 'set'
|
32
|
+
perform_key_validation
|
33
|
+
run_set
|
34
|
+
when 'unset'
|
35
|
+
perform_key_validation
|
36
|
+
run_unset
|
37
|
+
when 'list'
|
38
|
+
run_list
|
39
|
+
when 'list-decoded'
|
40
|
+
run_list_decoded
|
41
|
+
when 'create-pgbouncer-secret'
|
42
|
+
run_create_pgbouncer_secret
|
43
|
+
when 'bootstrap-cluster'
|
44
|
+
run_bootstrap_cluster
|
45
|
+
else
|
46
|
+
fail "Unknown command encountered"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def copy_secret_across_namespace(key:, to:, from:)
|
51
|
+
puts "Copying the #{key} secret from namespace #{from} to #{to}."
|
52
|
+
json_string = `kubectl get secret #{key} --namespace #{from} -o json`
|
53
|
+
secrets = JSON.parse(json_string)
|
54
|
+
|
55
|
+
# At this point we would preferably simply do a write_secrets call, but the metadata is highly coupled to old
|
56
|
+
# namespace so we need to clear out the old metadata
|
57
|
+
new_secrets = Marshal.load(Marshal.dump(secrets))
|
58
|
+
new_secrets.delete('metadata')
|
59
|
+
new_secrets['metadata'] = {
|
60
|
+
'name' => key,
|
61
|
+
'namespace' => to
|
62
|
+
}
|
63
|
+
write_secrets(secrets: new_secrets, secret_name: key)
|
64
|
+
end
|
65
|
+
|
66
|
+
def main_secret_name
|
67
|
+
"#{app}-secrets"
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def perform_key_validation
|
73
|
+
if key.nil? || key.strip == ""
|
74
|
+
puts "Please specify a key in all caps and with underscores"
|
75
|
+
exit(1)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def run_get
|
80
|
+
secrets = fetch_current_secrets
|
81
|
+
puts "#{key}: #{Base64.decode64(secrets['data'][key])}"
|
82
|
+
end
|
83
|
+
|
84
|
+
def run_set
|
85
|
+
fail "Please specify a value as the third argument" if value.nil? || value.strip == ""
|
86
|
+
secrets = fetch_current_secrets
|
87
|
+
secrets['data'][key] = Base64.encode64(value)
|
88
|
+
write_secrets(secrets: secrets)
|
89
|
+
end
|
90
|
+
|
91
|
+
def run_unset
|
92
|
+
secrets = fetch_current_secrets
|
93
|
+
secrets['data'].delete(key)
|
94
|
+
write_secrets(secrets: secrets)
|
95
|
+
end
|
96
|
+
|
97
|
+
def run_list
|
98
|
+
secrets = fetch_current_secrets
|
99
|
+
puts "Base64 encoded keys for #{app}:"
|
100
|
+
secrets['data'].each do |k, v|
|
101
|
+
puts "#{k}: #{v}"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def run_list_decoded
|
106
|
+
secrets = fetch_current_secrets
|
107
|
+
puts "Decoded (raw) keys for #{app}:"
|
108
|
+
secrets['data'].each do |k, v|
|
109
|
+
puts "#{k}: #{Base64.decode64(v)}"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def run_create_pgbouncer_secret
|
114
|
+
db_user = args[0]
|
115
|
+
db_password = args[1]
|
116
|
+
puts `kubectl create secret generic #{PGBOUNCER_SECRETS_NAME} --namespace #{app} --from-literal=DB_USER=#{db_user} --from-literal=DB_PASSWORD=#{db_password}`
|
117
|
+
end
|
118
|
+
|
119
|
+
# In the normal case the secret we are updating is just main_secret_name,
|
120
|
+
# but in special cases we may be doing an operation on a different secret
|
121
|
+
def write_secrets(secrets:, secret_name: main_secret_name)
|
122
|
+
file_name = "tmp/temp-secrets-#{Seira::Cluster.current_cluster}-#{secret_name}.json"
|
123
|
+
File.open(file_name, "wb") do |f|
|
124
|
+
f.write(secrets.to_json)
|
125
|
+
end
|
126
|
+
|
127
|
+
# The command we use depends on if it already exists or not
|
128
|
+
secret_exists = system("kubectl get secret #{secret_name} --namespace #{app} > /dev/null")
|
129
|
+
command = secret_exists ? "replace" : "create"
|
130
|
+
|
131
|
+
if system("kubectl #{command} --namespace #{app} -f #{file_name}")
|
132
|
+
puts "Successfully created/replaced #{secret_name} secret #{key} in cluster #{Seira::Cluster.current_cluster}"
|
133
|
+
else
|
134
|
+
puts "Failed to update secret"
|
135
|
+
end
|
136
|
+
|
137
|
+
File.delete(file_name)
|
138
|
+
end
|
139
|
+
|
140
|
+
# Returns the still-base64encoded secrets hashmap
|
141
|
+
def fetch_current_secrets
|
142
|
+
json_string = `kubectl get secret #{main_secret_name} --namespace #{app} -o json`
|
143
|
+
json = JSON.parse(json_string)
|
144
|
+
fail "Unexpected Kind" unless json['kind'] == 'Secret'
|
145
|
+
json
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'yaml'
|
3
|
+
|
4
|
+
module Seira
|
5
|
+
class Settings
|
6
|
+
DEFAULT_CONFIG_PATH = '.seira.yml'.freeze
|
7
|
+
|
8
|
+
attr_reader :config_path
|
9
|
+
|
10
|
+
def initialize(config_path: DEFAULT_CONFIG_PATH)
|
11
|
+
@config_path = config_path
|
12
|
+
end
|
13
|
+
|
14
|
+
def settings
|
15
|
+
return @_settings if defined?(@_settings)
|
16
|
+
@_settings = parse_settings
|
17
|
+
end
|
18
|
+
|
19
|
+
def organization_id
|
20
|
+
settings['seira']['organization_id']
|
21
|
+
end
|
22
|
+
|
23
|
+
def default_zone
|
24
|
+
settings['seira']['default_zone']
|
25
|
+
end
|
26
|
+
|
27
|
+
def valid_apps
|
28
|
+
settings['seira']['valid_apps']
|
29
|
+
end
|
30
|
+
|
31
|
+
def valid_cluster_names
|
32
|
+
settings['seira']['clusters'].keys
|
33
|
+
end
|
34
|
+
|
35
|
+
def clusters
|
36
|
+
settings['seira']['clusters']
|
37
|
+
end
|
38
|
+
|
39
|
+
def full_cluster_name_for_shorthand(shorthand)
|
40
|
+
return shorthand if valid_cluster_names.include?(shorthand)
|
41
|
+
|
42
|
+
# Try iterating through each cluster to find the relevant alias
|
43
|
+
clusters.each do |cluster_name, cluster_metadata|
|
44
|
+
next if cluster_metadata['aliases'].empty?
|
45
|
+
return cluster_name if cluster_metadata['aliases'].include?(shorthand)
|
46
|
+
end
|
47
|
+
|
48
|
+
nil
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def parse_settings
|
54
|
+
raw_settings = YAML.load_file(config_path)
|
55
|
+
puts raw_settings.inspect
|
56
|
+
raw_settings
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/seira/setup.rb
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Seira
|
5
|
+
class Setup
|
6
|
+
attr_reader :arg, :settings
|
7
|
+
|
8
|
+
def initialize(arg:, settings:)
|
9
|
+
@arg = arg
|
10
|
+
@settings = settings
|
11
|
+
end
|
12
|
+
|
13
|
+
# This script should be all that's needed to fully set up gcloud and kubectl cli, fully configured,
|
14
|
+
# on a development machine.
|
15
|
+
def run
|
16
|
+
ensure_software_installed
|
17
|
+
|
18
|
+
if arg == 'all'
|
19
|
+
puts "We will now set up gcloud and kubectl for each project. We use a distinct GCP Project for each environment: #{ENVIRONMENTS.join(', ')}"
|
20
|
+
settings.valid_cluster_names.each do |cluster|
|
21
|
+
setup_cluster(cluster)
|
22
|
+
end
|
23
|
+
elsif settings.valid_cluster_names.include?(arg)
|
24
|
+
puts "We will now set up gcloud and kubectl for #{arg}"
|
25
|
+
setup_cluster(arg)
|
26
|
+
else
|
27
|
+
puts "Please specify a valid cluster name or 'all'."
|
28
|
+
exit(1)
|
29
|
+
end
|
30
|
+
|
31
|
+
puts "You have now configured all of your configurations. Please note that 'gcloud' and 'kubectl' are two separate command line tools."
|
32
|
+
puts "gcloud: For manipulating GCP entities such as sql databases and kubernetes clusters themselves"
|
33
|
+
puts "kubectl: For working within a kubernetes cluster, such as listing pods and deployment statuses"
|
34
|
+
puts "Always remember to update both by using 'seira <cluster>', such as 'seira staging'."
|
35
|
+
puts "Except for special circumstances, you should be able to always use 'seira' tool and avoid `gcloud` and `kubectl` directly."
|
36
|
+
puts "All set!"
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def setup_cluster(cluster_name)
|
42
|
+
cluster_metadata = settings.clusters[cluster_name]
|
43
|
+
|
44
|
+
if system("gcloud config configurations describe #{cluster_name}")
|
45
|
+
puts "Configuration already exists for #{cluster_name}..."
|
46
|
+
else
|
47
|
+
puts "Creating configuration for this cluster and activating it..."
|
48
|
+
system("gcloud config configurations create #{cluster_name}")
|
49
|
+
end
|
50
|
+
|
51
|
+
system("gcloud config configurations activate #{cluster_name}")
|
52
|
+
|
53
|
+
# TODO: Is this possible to automate?
|
54
|
+
# system("gcloud iam service-accounts create #{iam_user} --display-name=#{iam_user}")
|
55
|
+
# puts "Created service account:"
|
56
|
+
# system("gcloud iam service-accounts describe #{iam_user}@#{cluster_metadata['project']}.iam.gserviceaccount.com")
|
57
|
+
puts "First,"
|
58
|
+
puts "First, set up a service account in the #{cluster_metadata['project']} project and download the credentials for it. You may do so by accessing the below link. Save the file in a safe location."
|
59
|
+
puts "https://console.cloud.google.com/iam-admin/serviceaccounts/project?project=#{cluster_metadata['project']}&organizationId=#{settings.organization_id}"
|
60
|
+
puts "Then, set up an IAM user that it will inherit the permissions for."
|
61
|
+
|
62
|
+
puts "Please enter the path of your JSON key:"
|
63
|
+
filename = STDIN.gets
|
64
|
+
puts "Activating service account..."
|
65
|
+
system("gcloud auth activate-service-account --key-file #{filename}")
|
66
|
+
system("gcloud config set project #{cluster_metadata['project']}")
|
67
|
+
system("gcloud config set compute/zone #{settings.default_zone}")
|
68
|
+
puts "Your new gcloud setup for #{cluster_name}:"
|
69
|
+
system("gcloud config configurations describe #{cluster_name}")
|
70
|
+
|
71
|
+
puts "Configuring kubectl for interactions with this project's kubernetes cluster"
|
72
|
+
system("gcloud container clusters get-credentials #{cluster_name} --zone #{settings.default_zone} --project #{cluster_metadata['project']}")
|
73
|
+
puts "Your kubectl is set up with:"
|
74
|
+
system("kubectl config current-context")
|
75
|
+
end
|
76
|
+
|
77
|
+
def ensure_software_installed
|
78
|
+
puts "Making sure gcloud is installed..."
|
79
|
+
unless system('gcloud --version &> /dev/null')
|
80
|
+
puts "Installing gcloud..."
|
81
|
+
system('curl https://sdk.cloud.google.com | bash')
|
82
|
+
system('exec -l $SHELL')
|
83
|
+
system('gcloud init')
|
84
|
+
end
|
85
|
+
|
86
|
+
puts "Making sure kubectl is installed..."
|
87
|
+
unless system('kubectl version &> /dev/null')
|
88
|
+
puts "Installing kubectl..."
|
89
|
+
system('gcloud components install kubectl')
|
90
|
+
end
|
91
|
+
|
92
|
+
puts "Making sure kubernetes-helm is installed..."
|
93
|
+
unless system('helm version &> /dev/null')
|
94
|
+
puts "Installing helm..."
|
95
|
+
system('brew install kubernetes-helm')
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
data/seira.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'seira/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "seira"
|
8
|
+
spec.version = Seira::VERSION
|
9
|
+
spec.authors = ["Scott Ringwelski"]
|
10
|
+
spec.email = ["scott@joinhandshake.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{An opinionated library for building applications on Kubernetes.}
|
13
|
+
spec.description = %q{An opinionated library for building applications on Kubernetes.}
|
14
|
+
spec.homepage = "https://github.com/joinhandshake/seira"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
18
|
+
f.match(%r{^(test|spec|features)/})
|
19
|
+
end
|
20
|
+
spec.executables = ['seira']
|
21
|
+
spec.require_paths = ["lib"]
|
22
|
+
|
23
|
+
spec.add_runtime_dependency "highline"
|
24
|
+
|
25
|
+
spec.add_development_dependency "bundler", "~> 1.14"
|
26
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
27
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
28
|
+
spec.add_development_dependency "rubocop", "0.51.0"
|
29
|
+
end
|