hippo-cli 1.0.1 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -0
- data/cli/apply_config.rb +4 -5
- data/cli/apply_services.rb +3 -3
- data/cli/console.rb +6 -6
- data/cli/deploy.rb +11 -22
- data/cli/install.rb +22 -23
- data/cli/kubectl.rb +3 -3
- data/cli/secrets_edit.rb +34 -0
- data/cli/secrets_key.rb +20 -0
- data/lib/hippo.rb +19 -0
- data/lib/hippo/cli.rb +210 -0
- data/lib/hippo/deployment_monitor.rb +82 -0
- data/lib/hippo/error.rb +0 -12
- data/lib/hippo/image.rb +74 -0
- data/lib/hippo/manifest.rb +81 -0
- data/lib/hippo/object_definition.rb +63 -0
- data/lib/hippo/secret.rb +61 -114
- data/lib/hippo/secret_manager.rb +43 -32
- data/lib/hippo/stage.rb +157 -7
- data/lib/hippo/util.rb +56 -33
- data/lib/hippo/version.rb +1 -1
- data/template/Hippofile +12 -13
- data/template/config/{production/env-vars.yaml → env-vars.yaml} +0 -0
- data/template/jobs/{upgrade → deploy}/db-migration.yaml +0 -0
- data/template/stages/production.yaml +1 -0
- metadata +31 -16
- metadata.gz.sig +0 -0
- data/cli/build.rb +0 -16
- data/cli/edit-secret.rb +0 -45
- data/cli/objects.rb +0 -34
- data/cli/publish.rb +0 -17
- data/cli/secrets.rb +0 -23
- data/cli/status.rb +0 -15
- data/lib/hippo/build_spec.rb +0 -32
- data/lib/hippo/cli_steps.rb +0 -315
- data/lib/hippo/kubernetes.rb +0 -200
- data/lib/hippo/recipe.rb +0 -126
- data/lib/hippo/repository.rb +0 -122
- data/lib/hippo/yaml_part.rb +0 -47
data/lib/hippo/secret_manager.rb
CHANGED
@@ -1,41 +1,32 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'encryptor'
|
4
|
+
require 'openssl'
|
3
5
|
require 'base64'
|
4
6
|
require 'hippo/secret'
|
5
7
|
|
6
8
|
module Hippo
|
7
9
|
class SecretManager
|
8
|
-
attr_reader :recipe
|
9
10
|
attr_reader :stage
|
10
|
-
|
11
|
+
|
12
|
+
def initialize(stage)
|
13
|
+
@stage = stage
|
14
|
+
end
|
11
15
|
|
12
16
|
CIPHER = OpenSSL::Cipher.new('aes-256-gcm')
|
13
17
|
|
14
|
-
def
|
15
|
-
@
|
16
|
-
@stage = stage
|
18
|
+
def root
|
19
|
+
File.join(@stage.manifest.root, 'secrets', @stage.name)
|
17
20
|
end
|
18
21
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
def create_key
|
23
|
-
if key_available?
|
24
|
-
raise Hippo::Error, 'A key already exists on Kubernetes. Remove this first.'
|
25
|
-
end
|
22
|
+
def secret(name)
|
23
|
+
Secret.new(self, name)
|
24
|
+
end
|
26
25
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
'apiVersion' => 'v1',
|
32
|
-
'kind' => 'Secret',
|
33
|
-
'type' => 'hippo.adam.ac/secret-encryption-key',
|
34
|
-
'metadata' => { 'name' => 'hippo-secret-key', 'namespace' => @stage.namespace },
|
35
|
-
'data' => { 'key' => Base64.encode64(secret_key64).gsub("\n", '').strip }
|
36
|
-
}
|
37
|
-
@recipe.kubernetes.apply_with_kubectl(@stage, object.to_yaml)
|
38
|
-
@key = secret_key
|
26
|
+
def secrets
|
27
|
+
Dir[File.join(root, '*.{yml,yaml}')].map do |path|
|
28
|
+
secret(path.split('/').last.sub(/\.ya?ml\z/, ''))
|
29
|
+
end
|
39
30
|
end
|
40
31
|
|
41
32
|
# Download the current key from the Kubernetes API and set it as the
|
@@ -45,7 +36,7 @@ module Hippo
|
|
45
36
|
def download_key
|
46
37
|
return if @key
|
47
38
|
|
48
|
-
value = @
|
39
|
+
value = @stage.get('secret', 'hippo-secret-key').first
|
49
40
|
return if value.nil?
|
50
41
|
return if value.dig('data', 'key').nil?
|
51
42
|
|
@@ -54,22 +45,42 @@ module Hippo
|
|
54
45
|
raise unless e.message =~ /not found/
|
55
46
|
end
|
56
47
|
|
48
|
+
# Is there a key availale in this manager?
|
49
|
+
#
|
50
|
+
# @return [Boolean]
|
57
51
|
def key_available?
|
58
52
|
download_key
|
59
53
|
!@key.nil?
|
60
54
|
end
|
61
55
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
secret(path.split('/').last.sub(/\.ya?ml\z/, ''))
|
56
|
+
# Generate and publish a new secret key to the Kubernetes API.
|
57
|
+
#
|
58
|
+
# @return [void]
|
59
|
+
def create_key
|
60
|
+
if key_available?
|
61
|
+
raise Hippo::Error, 'A key already exists on Kubernetes. Remove this first.'
|
69
62
|
end
|
63
|
+
|
64
|
+
CIPHER.encrypt
|
65
|
+
secret_key = CIPHER.random_key
|
66
|
+
secret_key64 = Base64.encode64(secret_key).gsub("\n", '').strip
|
67
|
+
od = ObjectDefinition.new({
|
68
|
+
'apiVersion' => 'v1',
|
69
|
+
'kind' => 'Secret',
|
70
|
+
'type' => 'hippo.adam.ac/secret-encryption-key',
|
71
|
+
'metadata' => { 'name' => 'hippo-secret-key' },
|
72
|
+
'data' => { 'key' => Base64.encode64(secret_key64).gsub("\n", '').strip }
|
73
|
+
}, @stage)
|
74
|
+
@stage.apply(od)
|
75
|
+
@key = secret_key
|
70
76
|
end
|
71
77
|
|
78
|
+
# Encrypt a given value?
|
72
79
|
def encrypt(value)
|
80
|
+
unless key_available?
|
81
|
+
raise Error, 'Cannot encrypt values because there is no key'
|
82
|
+
end
|
83
|
+
|
73
84
|
CIPHER.encrypt
|
74
85
|
iv = CIPHER.random_iv
|
75
86
|
salt = SecureRandom.random_bytes(16)
|
data/lib/hippo/stage.rb
CHANGED
@@ -1,8 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'liquid'
|
4
|
+
require 'open3'
|
5
|
+
require 'hippo/secret_manager'
|
6
|
+
|
3
7
|
module Hippo
|
4
8
|
class Stage
|
5
|
-
|
9
|
+
attr_reader :manifest
|
10
|
+
|
11
|
+
def initialize(manifest, options)
|
12
|
+
@manifest = manifest
|
6
13
|
@options = options
|
7
14
|
end
|
8
15
|
|
@@ -22,24 +29,167 @@ module Hippo
|
|
22
29
|
@options['context']
|
23
30
|
end
|
24
31
|
|
32
|
+
def vars
|
33
|
+
@options['vars']
|
34
|
+
end
|
35
|
+
|
36
|
+
# These are the vars to represent this
|
25
37
|
def template_vars
|
26
38
|
{
|
27
39
|
'name' => name,
|
28
40
|
'branch' => branch,
|
29
41
|
'namespace' => namespace,
|
30
|
-
'
|
42
|
+
'context' => context,
|
43
|
+
'images' => @manifest.images.values.each_with_object({}) { |image, hash| hash[image.name] = image.image_path_for_branch(branch) },
|
44
|
+
'vars' => vars
|
31
45
|
}
|
32
46
|
end
|
33
47
|
|
34
|
-
|
35
|
-
|
48
|
+
# Return a new decorator object that can be passed to objects that
|
49
|
+
# would like to decorator things.
|
50
|
+
def decorator
|
51
|
+
proc do |data|
|
52
|
+
template = Liquid::Template.parse(data)
|
53
|
+
template.render(
|
54
|
+
'stage' => template_vars,
|
55
|
+
'manifest' => @manifest.template_vars
|
56
|
+
)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def objects(path)
|
61
|
+
@manifest.objects(path, decorator: decorator)
|
62
|
+
end
|
63
|
+
|
64
|
+
def secret_manager
|
65
|
+
@secret_manager ||= SecretManager.new(self)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Return an array of all deployments for this stage
|
69
|
+
#
|
70
|
+
# @return [Hash<String,Hippo::ObjectDefinition>]
|
71
|
+
def deployments
|
72
|
+
Util.create_object_definitions(objects('deployments'), self, required_kinds: ['Deployment'])
|
73
|
+
end
|
74
|
+
|
75
|
+
# Return an array of all services/ingresses for this stage
|
76
|
+
#
|
77
|
+
# @return [Hash<String,Hippo::ObjectDefinition>]
|
78
|
+
def services
|
79
|
+
Util.create_object_definitions(objects('services'), self, required_kinds: %w[Service Ingress NetworkPolicy])
|
80
|
+
end
|
81
|
+
|
82
|
+
# Return an array of all configuration objects
|
83
|
+
#
|
84
|
+
# @return [Hash<String,Hippo::ObjectDefinition>]
|
85
|
+
def configs
|
86
|
+
Util.create_object_definitions(objects('config'), self)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Return an array of all job objects
|
90
|
+
#
|
91
|
+
# @return [Hash<String,Hippo::ObjectDefinition>]
|
92
|
+
def jobs(type)
|
93
|
+
Util.create_object_definitions(objects("jobs/#{type}"), self)
|
36
94
|
end
|
37
95
|
|
38
|
-
|
96
|
+
# Return a kubectl command ready for use within this stage's
|
97
|
+
# namespace and context
|
98
|
+
#
|
99
|
+
# @return [Array<String>]
|
100
|
+
def kubectl(*commands)
|
101
|
+
prefix = ['kubectl']
|
102
|
+
prefix += ['--context', context] if context
|
103
|
+
prefix += ['-n', namespace]
|
104
|
+
prefix + commands
|
105
|
+
end
|
106
|
+
|
107
|
+
# Apply a series of objecst with
|
108
|
+
#
|
109
|
+
# @param objects [Array<Hippo::ObjectDefinition>]
|
110
|
+
# @return [Hash]
|
111
|
+
def apply(objects)
|
112
|
+
yaml_to_apply = objects.map(&:yaml).join("\n")
|
113
|
+
|
39
114
|
command = ['kubectl']
|
40
115
|
command += ['--context', context] if context
|
41
|
-
command += ['-
|
42
|
-
command
|
116
|
+
command += ['apply', '-f', '-']
|
117
|
+
Open3.popen3(command.join(' ')) do |stdin, stdout, stderr, wt|
|
118
|
+
stdin.puts yaml_to_apply
|
119
|
+
stdin.close
|
120
|
+
|
121
|
+
stdout = stdout.read.strip
|
122
|
+
stderr = stderr.read.strip
|
123
|
+
|
124
|
+
if wt.value.success?
|
125
|
+
stdout.split("\n").each_with_object({}) do |line, hash|
|
126
|
+
next unless line =~ %r{\A([\w\/\-\.]+) (\w+)\z}
|
127
|
+
|
128
|
+
object = Regexp.last_match(1)
|
129
|
+
status = Regexp.last_match(2)
|
130
|
+
hash[object] = status
|
131
|
+
|
132
|
+
status = "\e[32m#{status}\e[0m" unless status == 'unchanged'
|
133
|
+
puts "\e[37m====> #{object} #{status}\e[0m"
|
134
|
+
end
|
135
|
+
else
|
136
|
+
raise Error, "[kubectl] #{stderr}"
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Get some data from the kubernetes API
|
142
|
+
#
|
143
|
+
# @param names [Array<String>]
|
144
|
+
# @return [Array<Hippo::ObjectDefinition>]
|
145
|
+
def get(*names)
|
146
|
+
command = kubectl('get', '-o', 'yaml', *names)
|
147
|
+
Open3.popen3(*command) do |_, stdout, stderr, wt|
|
148
|
+
raise Error, "[kutectl] #{stderr.read}" unless wt.value.success?
|
149
|
+
|
150
|
+
yaml = YAML.safe_load(stdout.read, permitted_classes: [Time])
|
151
|
+
yaml = yaml['items'] || [yaml]
|
152
|
+
yaml.map { |y| ObjectDefinition.new(y, self, clean: true) }
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Delete an object from the kubernetes API
|
157
|
+
#
|
158
|
+
# @param names [Array<String>]
|
159
|
+
# @return [Boolean]
|
160
|
+
def delete(*names)
|
161
|
+
command = kubectl('delete', *names)
|
162
|
+
Open3.popen3(*command) do |_, stdout, stderr, wt|
|
163
|
+
if wt.value.success?
|
164
|
+
stdout.read.split("\n").each do |line|
|
165
|
+
puts "\e[37m====> #{line}\e[0m"
|
166
|
+
end
|
167
|
+
true
|
168
|
+
else
|
169
|
+
stderr = stderr.read
|
170
|
+
if stderr =~ /\" not found$/
|
171
|
+
false
|
172
|
+
else
|
173
|
+
raise Error, "[kutectl] #{stderr}"
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
# Wait for the named jobs to complete
|
180
|
+
def wait_for_jobs(names, times = 120)
|
181
|
+
jobs = nil
|
182
|
+
times.times do
|
183
|
+
jobs = get(*names)
|
184
|
+
|
185
|
+
if jobs.all? { |j| j['status']['active'].nil? }
|
186
|
+
return [false, jobs]
|
187
|
+
else
|
188
|
+
sleep 2
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
[true, jobs]
|
43
193
|
end
|
44
194
|
end
|
45
195
|
end
|
data/lib/hippo/util.rb
CHANGED
@@ -1,49 +1,72 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'yaml'
|
3
4
|
require 'hippo/error'
|
4
|
-
require 'hippo/
|
5
|
+
require 'hippo/object_definition'
|
5
6
|
|
6
7
|
module Hippo
|
7
8
|
module Util
|
8
|
-
|
9
|
-
|
10
|
-
|
9
|
+
class << self
|
10
|
+
def load_yaml_from_file(path, decorator: nil)
|
11
|
+
raise Error, "No file found at #{path} to load" unless File.file?(path)
|
11
12
|
|
12
|
-
|
13
|
-
|
14
|
-
|
13
|
+
file = File.read(path)
|
14
|
+
load_yaml_from_data(file, path: path, decorator: decorator)
|
15
|
+
end
|
16
|
+
|
17
|
+
def load_yaml_from_data(data, path: nil, decorator: nil)
|
18
|
+
data = decorator.call(data) if decorator
|
15
19
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
+
parts = data.split(/^\-\-\-\s*$/)
|
21
|
+
parts.each_with_index.each_with_object([]) do |(p, i), array|
|
22
|
+
begin
|
23
|
+
yaml = YAML.safe_load(p)
|
24
|
+
next unless yaml.is_a?(Hash)
|
25
|
+
|
26
|
+
array << yaml
|
27
|
+
rescue Psych::SyntaxError => e
|
28
|
+
raise Error, e.message.sub('(<unknown>): ', "(#{path}[#{i}]): ")
|
29
|
+
end
|
30
|
+
end
|
20
31
|
end
|
21
|
-
end
|
22
32
|
|
23
|
-
|
24
|
-
|
25
|
-
|
33
|
+
def create_object_definitions(hash, stage, required_kinds: nil, clean: false)
|
34
|
+
index = 0
|
35
|
+
hash.each_with_object([]) do |(path, objects), array|
|
36
|
+
objects.each_with_index do |object, inner_index|
|
37
|
+
od = ObjectDefinition.new(object, stage, clean: clean)
|
26
38
|
|
27
|
-
|
28
|
-
|
29
|
-
|
39
|
+
if od.name.nil?
|
40
|
+
raise Error, "All object defintions must have a name. Missing metadata.name for object in #{path} at index #{inner_index}"
|
41
|
+
end
|
30
42
|
|
31
|
-
|
32
|
-
|
33
|
-
|
43
|
+
if od.kind.nil?
|
44
|
+
raise Error, "All object definitions must have a kind defined. Check #{path} at index #{inner_index}"
|
45
|
+
end
|
46
|
+
|
47
|
+
if required_kinds && !required_kinds.include?(od.kind)
|
48
|
+
raise Error, "Kind '#{od.kind}' cannot be defined in #{path} at index #{inner_index}. Only kinds #{required_kinds} are permitted."
|
49
|
+
end
|
50
|
+
|
51
|
+
array << od
|
52
|
+
index += 1
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
34
56
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
57
|
+
def open_in_editor(name, contents)
|
58
|
+
tmp_root = File.join(ENV['HOME'], '.hippo')
|
59
|
+
FileUtils.mkdir_p(tmp_root)
|
60
|
+
begin
|
61
|
+
tmpfile = Tempfile.new([name, '.yaml'], tmp_root)
|
62
|
+
tmpfile.write(contents)
|
63
|
+
tmpfile.close
|
64
|
+
system("#{ENV['EDITOR']} #{tmpfile.path}")
|
65
|
+
tmpfile.open
|
66
|
+
tmpfile.read
|
67
|
+
ensure
|
68
|
+
tmpfile.unlink
|
69
|
+
end
|
47
70
|
end
|
48
71
|
end
|
49
72
|
end
|
data/lib/hippo/version.rb
CHANGED
data/template/Hippofile
CHANGED
@@ -3,17 +3,16 @@
|
|
3
3
|
# In here you configure how you wish to build, publish and
|
4
4
|
# deploy your application using Docker & Kubernetes.
|
5
5
|
|
6
|
-
|
7
|
-
repository:
|
8
|
-
url: git@github.com:username/repo
|
6
|
+
name: myapp
|
9
7
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
8
|
+
images:
|
9
|
+
main:
|
10
|
+
repository: git@github.com:myorg/myapp
|
11
|
+
url: myorg/myapp
|
12
|
+
|
13
|
+
# If you wish, you can define a console command that allows you to easil
|
14
|
+
# open a console using `hippo [stage] console`
|
15
|
+
#
|
16
|
+
# console:
|
17
|
+
# deployment: worker
|
18
|
+
# command: bundle exec rails console
|
File without changes
|