hippo-cli 1.1.0 → 1.1.1

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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data.tar.gz.sig +0 -0
  4. data/bin/hippo +4 -4
  5. data/cli/apply_config.rb +1 -0
  6. data/cli/apply_services.rb +1 -0
  7. data/cli/console.rb +2 -0
  8. data/cli/create.rb +83 -0
  9. data/cli/deploy.rb +2 -0
  10. data/cli/init.rb +1 -1
  11. data/cli/install.rb +3 -1
  12. data/cli/key.rb +34 -0
  13. data/cli/kubectl.rb +2 -0
  14. data/cli/logs.rb +56 -0
  15. data/cli/objects.rb +50 -0
  16. data/cli/package_install.rb +30 -0
  17. data/cli/package_list.rb +40 -0
  18. data/cli/package_notes.rb +23 -0
  19. data/cli/package_test.rb +21 -0
  20. data/cli/package_uninstall.rb +27 -0
  21. data/cli/package_upgrade.rb +30 -0
  22. data/cli/package_values.rb +22 -0
  23. data/cli/prepare.rb +18 -0
  24. data/cli/run.rb +37 -0
  25. data/cli/secrets.rb +24 -0
  26. data/cli/stages.rb +26 -0
  27. data/cli/status.rb +25 -0
  28. data/cli/vars.rb +20 -0
  29. data/cli/version.rb +8 -0
  30. data/lib/hippo.rb +12 -0
  31. data/lib/hippo/bootstrap_parser.rb +64 -0
  32. data/lib/hippo/cli.rb +47 -14
  33. data/lib/hippo/extensions.rb +9 -0
  34. data/lib/hippo/image.rb +35 -31
  35. data/lib/hippo/manifest.rb +17 -7
  36. data/lib/hippo/object_definition.rb +18 -5
  37. data/lib/hippo/package.rb +124 -0
  38. data/lib/hippo/repository_tag.rb +38 -0
  39. data/lib/hippo/secret_manager.rb +61 -14
  40. data/lib/hippo/stage.rb +60 -26
  41. data/lib/hippo/util.rb +34 -0
  42. data/lib/hippo/version.rb +1 -1
  43. data/template/Hippofile +10 -2
  44. metadata +44 -13
  45. metadata.gz.sig +0 -0
  46. data/cli/secrets_edit.rb +0 -34
  47. data/cli/secrets_key.rb +0 -20
  48. data/lib/hippo/secret.rb +0 -112
  49. data/template/config/env-vars.yaml +0 -7
  50. data/template/deployments/web.yaml +0 -29
  51. data/template/deployments/worker.yaml +0 -26
  52. data/template/jobs/deploy/db-migration.yaml +0 -22
  53. data/template/jobs/install/load-schema.yaml +0 -22
  54. data/template/services/main.ingress.yaml +0 -13
  55. data/template/services/web.svc.yaml +0 -11
  56. data/template/stages/production.yaml +0 -7
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hash
4
+ def deep_merge(second)
5
+ merger = proc { |_, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : Array === v1 && Array === v2 ? v1 | v2 : [:undefined, nil, :nil].include?(v2) ? v1 : v2 }
6
+ merge(second.to_h, &merger)
7
+ end
8
+ end
9
+ # From https://stackoverflow.com/questions/9381553/ruby-merge-nested-hash
data/lib/hippo/image.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'git'
4
4
  require 'net/http'
5
+ require 'hippo/repository_tag'
5
6
 
6
7
  module Hippo
7
8
  class Image
@@ -12,63 +13,66 @@ module Hippo
12
13
 
13
14
  attr_reader :name
14
15
 
15
- def url
16
- @options['url']
16
+ def host
17
+ @options['host']
17
18
  end
18
19
 
19
- def repository
20
- @options['repository']
20
+ def image_name
21
+ @options['name']
21
22
  end
22
23
 
23
- def template_vars
24
- {
25
- 'url' => url,
26
- 'repository' => repository
27
- }
24
+ def tag
25
+ @tag ||= begin
26
+ if @options['tag'].is_a?(Hash) && repo = @options['tag']['fromRepository']
27
+ RepositoryTag.new(repo)
28
+ elsif @options['tag'].nil?
29
+ 'latest'
30
+ else
31
+ @options['tag'].to_s
32
+ end
33
+ end
28
34
  end
29
35
 
30
- def commit_ref_for_branch(branch)
31
- remote_refs.dig('branches', branch, :sha)
36
+ def image_url
37
+ "#{host}/#{image_name}:#{tag}"
32
38
  end
33
39
 
34
- def image_path_for_branch(branch)
35
- "#{url}:#{commit_ref_for_branch(branch)}"
40
+ def template_vars
41
+ @template_vars ||= {
42
+ 'host' => host,
43
+ 'name' => image_name,
44
+ 'tag' => tag.to_s,
45
+ 'url' => image_url
46
+ }
36
47
  end
37
48
 
38
- def remote_refs
39
- @remote_refs ||= begin
40
- Git.ls_remote(repository)
41
- end
49
+ def can_check_for_existence?
50
+ @options['existenceCheck'].nil? ||
51
+ @options['existenceCheck'] == true
42
52
  end
43
53
 
44
- def exists_for_commit?(commit)
45
- credentials = Hippo.config.dig('docker', 'credentials', registry_host)
54
+ def exists?
55
+ return true unless can_check_for_existence?
46
56
 
47
- http = Net::HTTP.new(registry_host, 443)
57
+ credentials = Hippo.config.dig('docker', 'credentials', host)
58
+ http = Net::HTTP.new(host, 443)
48
59
  http.use_ssl = true
49
- request = Net::HTTP::Head.new("/v2/#{registry_image_name}/manifests/#{commit}")
60
+ request = Net::HTTP::Head.new("/v2/#{image_name}/manifests/#{tag}")
50
61
  if credentials
51
62
  request.basic_auth(credentials['username'], credentials['password'])
52
63
  end
53
64
  response = http.request(request)
65
+
54
66
  case response
55
67
  when Net::HTTPOK
56
68
  true
57
69
  when Net::HTTPUnauthorized
58
- raise Error, "Could not authenticate to #{registry_host} to verify image existence"
70
+ raise Error, "Could not authenticate to #{host} to verify image existence"
59
71
  when Net::HTTPNotFound
60
72
  false
61
73
  else
62
- raise Error, "Got #{response.code} status when verifying imag existence with #{registry_host}"
74
+ raise Error, "Got #{response.code} status when verifying imag existence with #{host}"
63
75
  end
64
76
  end
65
-
66
- def registry_host
67
- url.split('/').first
68
- end
69
-
70
- def registry_image_name
71
- url.split('/', 2).last
72
- end
73
77
  end
74
78
  end
@@ -36,21 +36,31 @@ module Hippo
36
36
  @options['console']
37
37
  end
38
38
 
39
+ def config
40
+ @options['config'] || {}
41
+ end
42
+
43
+ def bootstrap
44
+ @bootstrap ||= begin
45
+ bootstrap_file = File.join(@root, 'bootstrap.yaml')
46
+ if File.file?(bootstrap_file)
47
+ YAML.load_file(bootstrap_file)
48
+ else
49
+ {}
50
+ end
51
+ end
52
+ end
53
+
39
54
  def template_vars
40
55
  {
41
- 'name' => name,
42
- 'images' => images.each_with_object({}) { |(name, image), hash| hash[name.to_s] = image.template_vars }
56
+ 'name' => name
43
57
  }
44
58
  end
45
59
 
46
60
  def images
47
61
  return {} unless @options['images'].is_a?(Hash)
48
62
 
49
- @images ||= begin
50
- @options['images'].each_with_object({}) do |(key, value), hash|
51
- hash[key] = Image.new(key, value)
52
- end
53
- end
63
+ @options['images']
54
64
  end
55
65
 
56
66
  # Load all stages that are available in the manifest
@@ -7,11 +7,6 @@ module Hippo
7
7
  def initialize(object, stage, clean: false)
8
8
  @object = object
9
9
  @stage = stage
10
-
11
- unless clean
12
- insert_namespace!
13
- insert_default_labels!
14
- end
15
10
  end
16
11
 
17
12
  def [](name)
@@ -38,6 +33,24 @@ module Hippo
38
33
  @object.to_yaml
39
34
  end
40
35
 
36
+ def yaml_to_apply
37
+ object = ObjectDefinition.new(@object.dup, @stage)
38
+ object.insert_namespace!
39
+ object.insert_default_labels!
40
+ object.base64_encode_data! if kind == 'Secret'
41
+ object.yaml
42
+ end
43
+
44
+ def base64_encode_data!(object = @object['data'])
45
+ object.each do |key, value|
46
+ object[key] = if value.is_a?(Hash)
47
+ base64_encode_data!(value)
48
+ else
49
+ Base64.encode64(value.to_s).gsub(/\n/, '').strip
50
+ end
51
+ end
52
+ end
53
+
41
54
  def insert_namespace!
42
55
  metadata['namespace'] = @stage.namespace
43
56
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hippo/cli'
4
+ require 'hippo/extensions'
5
+
6
+ module Hippo
7
+ class Package
8
+ def initialize(options, stage)
9
+ @options = options
10
+ @stage = stage
11
+ end
12
+
13
+ # Return the name of the package (i.e. the release name)
14
+ # and how this package will be referred.
15
+ #
16
+ # @return [String]
17
+ def name
18
+ @options['name']
19
+ end
20
+
21
+ # Return the name of the package to be installed.
22
+ # Including the registry.
23
+ #
24
+ # @return [String]
25
+ def package
26
+ @options['package']
27
+ end
28
+
29
+ # return values defined in the package's manifest file
30
+ #
31
+ # @return [Hash]
32
+ def values
33
+ @options['values']
34
+ end
35
+
36
+ # Compile a set of final values which should be used when
37
+ # upgrading and installing this package.
38
+ #
39
+ # @return [Hash]
40
+ def final_values
41
+ overrides = @stage.overridden_package_values[name]
42
+ values.deep_merge(overrides)
43
+ end
44
+
45
+ # Install this package
46
+ #
47
+ # @return [void]
48
+ def install
49
+ run_install_command('install')
50
+ end
51
+
52
+ # Upgrade this package
53
+ #
54
+ # @return [void]
55
+ def upgrade
56
+ run_install_command('upgrade', '--history-max', @options['max-revisions'] ? @options['max-revisions'].to_i.to_s : '5')
57
+ end
58
+
59
+ # Uninstall this packgae
60
+ #
61
+ # @return [void]
62
+ def uninstall
63
+ run(helm('uninstall', name))
64
+ end
65
+
66
+ # Is this release currently installed for the stage?
67
+ #
68
+ # @return [Boolean]
69
+ def installed?
70
+ secrets = @stage.get('secrets').map(&:name)
71
+ secrets.any? { |s| s.match(/\Ash\.helm\.release\.v\d+\.#{Regexp.escape(name)}\./) }
72
+ end
73
+
74
+ # Return the notes for this package
75
+ #
76
+ # @return [String]
77
+ def notes
78
+ run(helm('get', 'notes', name))
79
+ end
80
+
81
+ def helm(*commands)
82
+ command = ['helm']
83
+ command += ['--kube-context', @stage.context] if @stage.context
84
+ command += ['-n', @stage.namespace]
85
+ command += commands
86
+ command
87
+ end
88
+
89
+ private
90
+
91
+ def install_command(verb, *additional)
92
+ helm(verb, name, package, '-f', '-', *additional)
93
+ end
94
+
95
+ def run_install_command(verb, *additional)
96
+ run(install_command(verb, *additional), stdin: final_values.to_yaml)
97
+ true
98
+ end
99
+
100
+ def run(command, stdin: nil)
101
+ stdout, stderr, status = Open3.capture3(*command, stdin_data: stdin)
102
+ raise Error, "[helm] #{stderr}" unless status.success?
103
+
104
+ stdout
105
+ end
106
+
107
+ class << self
108
+ def setup_from_cli_context(context)
109
+ cli = Hippo::CLI.setup(context)
110
+ package_name = context.options[:package]
111
+ if package_name.nil? || package_name.empty?
112
+ raise Error, 'A package name must be provided in -p or --package'
113
+ end
114
+
115
+ package = cli.stage.packages[package_name]
116
+ if package.nil?
117
+ raise Error, "No package named '#{package_name}' has been defined"
118
+ end
119
+
120
+ [package, cli]
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hippo
4
+ class RepositoryTag
5
+ def initialize(options)
6
+ @options = options
7
+ end
8
+
9
+ def branch
10
+ @options['branch'] || 'master'
11
+ end
12
+
13
+ def tag
14
+ @tag ||= commit_ref_for_branch(branch)
15
+ end
16
+
17
+ def to_s
18
+ tag
19
+ end
20
+
21
+ private
22
+
23
+ def commit_ref_for_branch(branch)
24
+ return nil if remote_refs.nil?
25
+
26
+ remote_refs.dig('branches', branch, :sha)
27
+ end
28
+
29
+ def remote_refs
30
+ return nil if @options['url'].nil?
31
+
32
+ @remote_refs ||= begin
33
+ puts "Getting remote refs from #{@options['url']}"
34
+ Git.ls_remote(@options['url'])
35
+ end
36
+ end
37
+ end
38
+ end
@@ -3,7 +3,6 @@
3
3
  require 'encryptor'
4
4
  require 'openssl'
5
5
  require 'base64'
6
- require 'hippo/secret'
7
6
 
8
7
  module Hippo
9
8
  class SecretManager
@@ -15,8 +14,8 @@ module Hippo
15
14
 
16
15
  CIPHER = OpenSSL::Cipher.new('aes-256-gcm')
17
16
 
18
- def root
19
- File.join(@stage.manifest.root, 'secrets', @stage.name)
17
+ def path
18
+ File.join(@stage.manifest.root, 'secrets', @stage.name + '.yaml')
20
19
  end
21
20
 
22
21
  def secret(name)
@@ -69,9 +68,9 @@ module Hippo
69
68
  'kind' => 'Secret',
70
69
  'type' => 'hippo.adam.ac/secret-encryption-key',
71
70
  'metadata' => { 'name' => 'hippo-secret-key' },
72
- 'data' => { 'key' => Base64.encode64(secret_key64).gsub("\n", '').strip }
71
+ 'data' => { 'key' => secret_key64 }
73
72
  }, @stage)
74
- @stage.apply(od)
73
+ @stage.apply([od])
75
74
  @key = secret_key
76
75
  end
77
76
 
@@ -85,11 +84,11 @@ module Hippo
85
84
  iv = CIPHER.random_iv
86
85
  salt = SecureRandom.random_bytes(16)
87
86
  encrypted_value = Encryptor.encrypt(value: value.to_s, key: @key, iv: iv, salt: salt)
88
- 'encrypted:' + Base64.encode64([
87
+ Base64.encode64([
89
88
  Base64.encode64(encrypted_value),
90
89
  Base64.encode64(salt),
91
90
  Base64.encode64(iv)
92
- ].join('---')).gsub("\n", '')
91
+ ].join('---'))
93
92
  end
94
93
 
95
94
  # Decrypt the given value value and return it
@@ -97,14 +96,62 @@ module Hippo
97
96
  # @param value [String]
98
97
  # @return [String]
99
98
  def decrypt(value)
100
- value = value.to_s
101
- if value =~ /\Aencrypted:(.*)/
102
- value = Base64.decode64(Regexp.last_match(1))
103
- encrypted_value, salt, iv = value.split('---', 3).map { |s| Base64.decode64(s) }
104
- Encryptor.decrypt(value: encrypted_value, key: @key, iv: iv, salt: salt).to_s
105
- else
106
- value
99
+ value = Base64.decode64(value.to_s)
100
+ encrypted_value, salt, iv = value.split('---', 3).map { |s| Base64.decode64(s) }
101
+ Encryptor.decrypt(value: encrypted_value, key: @key, iv: iv, salt: salt).to_s
102
+ end
103
+
104
+ # Does a secrets file exist for this application.
105
+ #
106
+ # @return [Boolean]
107
+ def exists?
108
+ File.file?(path)
109
+ end
110
+
111
+ # Create an empty encrypted example secret file
112
+ #
113
+ # @return [void]
114
+ def create
115
+ unless key_available?
116
+ raise Error, 'Cannot create secret file because no key is available for encryption'
117
+ end
118
+
119
+ return if exists?
120
+
121
+ yaml = { 'example' => 'This is an example secret!' }.to_yaml
122
+ FileUtils.mkdir_p(File.dirname(path))
123
+ File.open(path, 'w') { |f| f.write(encrypt(yaml)) }
124
+ end
125
+
126
+ def edit
127
+ create unless exists?
128
+
129
+ unless key_available?
130
+ raise Error, 'Cannot create edit file because no key is available for decryption'
131
+ end
132
+
133
+ contents = decrypt(File.read(path))
134
+ contents = Util.open_in_editor('secret', contents)
135
+ write_file(contents)
136
+ end
137
+
138
+ def write_file(contents)
139
+ FileUtils.mkdir_p(File.dirname(path))
140
+ File.open(path, 'w') { |f| f.write(encrypt(contents)) }
141
+ end
142
+
143
+ def all
144
+ @all ||= begin
145
+ return {} unless exists?
146
+
147
+ unless key_available?
148
+ raise Error, 'No encryption key is available to decrypt secrets'
149
+ end
150
+
151
+ YAML.safe_load(decrypt(File.read(path)))
107
152
  end
153
+ rescue Psych::SyntaxError => e
154
+ raise Error, "Could not parse secrets file: #{e.message}"
108
155
  end
109
156
  end
110
157
  end