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.
@@ -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
- attr_reader :key
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 initialize(recipe, stage)
15
- @recipe = recipe
16
- @stage = stage
18
+ def root
19
+ File.join(@stage.manifest.root, 'secrets', @stage.name)
17
20
  end
18
21
 
19
- # Generate and publish a new secret key to the Kubernetes API.
20
- #
21
- # @return [void]
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
- CIPHER.encrypt
28
- secret_key = CIPHER.random_key
29
- secret_key64 = Base64.encode64(secret_key).gsub("\n", '').strip
30
- object = {
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 = @recipe.kubernetes.get_with_kubectl(@stage, 'secret', 'hippo-secret-key').first
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
- def secret(name)
63
- Secret.new(self, name)
64
- end
65
-
66
- def secrets
67
- Dir[File.join('secrets', @stage.name, '**', '*.{yml,yaml}')].map do |path|
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)
@@ -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
- def initialize(options)
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
- 'vars' => @options['vars'] || {}
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
- def kubectl(*command)
35
- (kubectl_base_command + command).join(' ')
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
- def kubectl_base_command
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 += ['-n', namespace]
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
@@ -1,49 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'yaml'
3
4
  require 'hippo/error'
4
- require 'hippo/yaml_part'
5
+ require 'hippo/object_definition'
5
6
 
6
7
  module Hippo
7
8
  module Util
8
- def load_yaml_from_path(path)
9
- return nil if path.nil?
10
- return nil unless File.file?(path)
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
- data = File.read(path)
13
- load_yaml_from_data(data, path)
14
- end
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
- def load_yaml_from_data(data, path = nil)
17
- parts = data.split(/^\-\-\-$/)
18
- parts.each_with_index.map do |part, index|
19
- YAMLPart.new(part, path, index)
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
- def load_yaml_from_directory(path)
24
- return [] if path.nil?
25
- return [] unless File.directory?(path)
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
- Dir[File.join(path, '*.{yaml,yml}')].sort.each_with_object([]) do |path, array|
28
- yaml = load_yaml_from_path(path)
29
- next if yaml.nil?
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
- array << yaml
32
- end.flatten
33
- end
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
- def open_in_editor(name, contents)
36
- tmp_root = File.join(ENV['HOME'], '.hippo')
37
- FileUtils.mkdir_p(tmp_root)
38
- begin
39
- tmpfile = Tempfile.new([name, '.yaml'], tmp_root)
40
- tmpfile.write(contents)
41
- tmpfile.close
42
- system("#{ENV['EDITOR']} #{tmpfile.path}")
43
- tmpfile.open
44
- tmpfile.read
45
- ensure
46
- tmpfile.unlink
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hippo
4
- VERSION = '1.0.1'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -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
- # Where is the application that you wish to deploy hosted?
7
- repository:
8
- url: git@github.com:username/repo
6
+ name: myapp
9
7
 
10
- # You can request multiple builds from the same repository to be built if
11
- # required. In most cases you'll probably only need one. You can reference
12
- # these in your Kubernetes objects to specify image details in PodSpecs.
13
- builds:
14
- app:
15
- dockerfile: Dockerfile
16
- # This is the name of the image where the built-image should be uploaded
17
- # to when build. You should not include any tag after the name. The
18
- # commit ref that you're building will be automatically added.
19
- image-name: myorg/myapp
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