hippo-cli 1.0.1 → 1.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.
@@ -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