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