seira 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Seira
2
+ VERSION = "0.1.0"
3
+ end
@@ -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