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