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.
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hippo
4
+ class DeploymentMonitor
5
+ def initialize(stage, deployment_id, sleep: 4, count: 15)
6
+ @stage = stage
7
+ @deployment_id = deployment_id
8
+ @sleep = sleep
9
+ @count = count
10
+ end
11
+
12
+ def on_wait(&block)
13
+ @on_wait = block
14
+ end
15
+
16
+ def on_failure(&block)
17
+ @on_failure = block
18
+ end
19
+
20
+ def on_success(&block)
21
+ @on_success = block
22
+ end
23
+
24
+ def wait
25
+ count = 0
26
+ loop do
27
+ sleep @sleep
28
+ poll = Poll.new(@stage, @deployment_id)
29
+ if poll.pending.empty?
30
+ @on_success&.call(poll)
31
+ return true
32
+ else
33
+ if count >= @count
34
+ @on_failure&.call(poll)
35
+ return false
36
+ else
37
+ count += 1
38
+ @on_wait&.call(poll)
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ class Poll
47
+ def initialize(stage, deployment_id)
48
+ @stage = stage
49
+ @deployment_id = deployment_id
50
+
51
+ @replica_sets = @stage.get(
52
+ 'rs',
53
+ '--selector',
54
+ 'hippo.adam.ac/deployID=' + @deployment_id
55
+ )
56
+
57
+ @pending = @replica_sets.reject do |deploy|
58
+ deploy['status']['availableReplicas'] == deploy['status']['replicas']
59
+ end
60
+ end
61
+
62
+ attr_reader :pending
63
+ attr_reader :replica_sets
64
+
65
+ def pending_names
66
+ make_names(@pending)
67
+ end
68
+
69
+ def names
70
+ make_names(@replica_sets)
71
+ end
72
+
73
+ private
74
+
75
+ def make_names(array)
76
+ array.map do |d|
77
+ d.name.split('-').first
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -3,16 +3,4 @@
3
3
  module Hippo
4
4
  class Error < StandardError
5
5
  end
6
-
7
- class RepositoryAlreadyClonedError < Error
8
- end
9
-
10
- class RepositoryCloneError < Error
11
- end
12
-
13
- class RepositoryFetchError < Error
14
- end
15
-
16
- class RepositoryCheckoutError < Error
17
- end
18
6
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git'
4
+ require 'net/http'
5
+
6
+ module Hippo
7
+ class Image
8
+ def initialize(name, options)
9
+ @name = name
10
+ @options = options
11
+ end
12
+
13
+ attr_reader :name
14
+
15
+ def url
16
+ @options['url']
17
+ end
18
+
19
+ def repository
20
+ @options['repository']
21
+ end
22
+
23
+ def template_vars
24
+ {
25
+ 'url' => url,
26
+ 'repository' => repository
27
+ }
28
+ end
29
+
30
+ def commit_ref_for_branch(branch)
31
+ remote_refs.dig('branches', branch, :sha)
32
+ end
33
+
34
+ def image_path_for_branch(branch)
35
+ "#{url}:#{commit_ref_for_branch(branch)}"
36
+ end
37
+
38
+ def remote_refs
39
+ @remote_refs ||= begin
40
+ Git.ls_remote(repository)
41
+ end
42
+ end
43
+
44
+ def exists_for_commit?(commit)
45
+ credentials = Hippo.config.dig('docker', 'credentials', registry_host)
46
+
47
+ http = Net::HTTP.new(registry_host, 443)
48
+ http.use_ssl = true
49
+ request = Net::HTTP::Head.new("/v2/#{registry_image_name}/manifests/#{commit}")
50
+ if credentials
51
+ request.basic_auth(credentials['username'], credentials['password'])
52
+ end
53
+ response = http.request(request)
54
+ case response
55
+ when Net::HTTPOK
56
+ true
57
+ when Net::HTTPUnauthorized
58
+ raise Error, "Could not authenticate to #{registry_host} to verify image existence"
59
+ when Net::HTTPNotFound
60
+ false
61
+ else
62
+ raise Error, "Got #{response.code} status when verifying imag existence with #{registry_host}"
63
+ end
64
+ 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
+ end
74
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hippo/util'
4
+ require 'hippo/stage'
5
+ require 'hippo/image'
6
+
7
+ module Hippo
8
+ class Manifest
9
+ # Load a new manifest from a given Hippofile.
10
+ #
11
+ # @param path [String]
12
+ # @return [Hippo::Manifest]
13
+ class << self
14
+ def load_from_file(path)
15
+ unless File.file?(path)
16
+ raise Error, "Hippofile file not found at #{path}"
17
+ end
18
+
19
+ root = File.dirname(path)
20
+ new(Util.load_yaml_from_file(path).first, root)
21
+ end
22
+ end
23
+
24
+ attr_reader :root
25
+
26
+ def initialize(options, root)
27
+ @options = options
28
+ @root = File.expand_path(root)
29
+ end
30
+
31
+ def name
32
+ @options['name'] || 'app'
33
+ end
34
+
35
+ def console
36
+ @options['console']
37
+ end
38
+
39
+ def template_vars
40
+ {
41
+ 'name' => name,
42
+ 'images' => images.each_with_object({}) { |(name, image), hash| hash[name.to_s] = image.template_vars }
43
+ }
44
+ end
45
+
46
+ def images
47
+ return {} unless @options['images'].is_a?(Hash)
48
+
49
+ @images ||= begin
50
+ @options['images'].each_with_object({}) do |(key, value), hash|
51
+ hash[key] = Image.new(key, value)
52
+ end
53
+ end
54
+ end
55
+
56
+ # Load all stages that are available in the manifest
57
+ #
58
+ # @return [Hash<Symbol, Hippo::Stage>]
59
+ def stages
60
+ objects('stages').each_with_object({}) do |(_, objects), hash|
61
+ objects.each do |obj|
62
+ stage = Stage.new(self, obj)
63
+ hash[stage.name] = stage
64
+ end
65
+ end
66
+ end
67
+
68
+ # Load all YAML objects at a given path and return them.
69
+ #
70
+ # @param path [String]
71
+ # @param decorator [Proc] an optional parser to run across the raw YAML file
72
+ # @return [Array<Hash>]
73
+ def objects(path, decorator: nil)
74
+ files = Dir[File.join(@root, path, '*.{yaml,yml}')]
75
+ files.each_with_object({}) do |path, objects|
76
+ file = Util.load_yaml_from_file(path, decorator: decorator)
77
+ objects[path.sub(%r{\A#{@root}/}, '')] = file
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Hippo
6
+ class ObjectDefinition
7
+ def initialize(object, stage, clean: false)
8
+ @object = object
9
+ @stage = stage
10
+
11
+ unless clean
12
+ insert_namespace!
13
+ insert_default_labels!
14
+ end
15
+ end
16
+
17
+ def [](name)
18
+ @object[name]
19
+ end
20
+
21
+ def dig(*args)
22
+ @object.dig(*args)
23
+ end
24
+
25
+ def name
26
+ metadata['name']
27
+ end
28
+
29
+ def metadata
30
+ @object['metadata'] ||= {}
31
+ end
32
+
33
+ def kind
34
+ @object['kind']
35
+ end
36
+
37
+ def yaml
38
+ @object.to_yaml
39
+ end
40
+
41
+ def insert_namespace!
42
+ metadata['namespace'] = @stage.namespace
43
+ end
44
+
45
+ def insert_default_labels!
46
+ metadata['labels'] ||= {}
47
+ metadata['labels']['app.kubernetes.io/name'] = @stage.manifest.name
48
+ metadata['labels']['app.kubernetes.io/instance'] = @stage.name
49
+ metadata['labels']['app.kubernetes.io/managed-by'] = 'hippo'
50
+ end
51
+
52
+ def insert_deployment_id!(deployment_id)
53
+ metadata['labels'] ||= {}
54
+ metadata['labels']['hippo.adam.ac/deployID'] = deployment_id
55
+
56
+ # For deployments, insert the ID on the template too for deployments.
57
+ if kind == 'Deployment' && pod_metadata = @object.dig('spec', 'template', 'metadata')
58
+ pod_metadata['labels'] ||= {}
59
+ pod_metadata['labels']['hippo.adam.ac/deployID'] = deployment_id
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,165 +1,112 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'securerandom'
4
- require 'openssl'
5
- require 'encryptor'
6
- require 'hippo/util'
7
-
8
3
  module Hippo
9
4
  class Secret
10
- include Hippo::Util
11
-
12
5
  HEADER = [
13
6
  '# This file is encrypted and managed by Hippo.',
14
- '# Use `hippo secret [stage] [name]` to make changes to it.',
7
+ '# Use `hippo [stage] secrets:edit [name]` to make changes to it.',
15
8
  '#',
16
9
  '# Note: this cannot be applied directly to your Kubernetes server because',
17
10
  '# HippoEncryptedSecret is not a valid object. It will be automatically ',
18
11
  '# converted to a Secret when it is applied by Hippo.'
19
12
  ].join("\n")
20
13
 
14
+ EDIT_HEADER = [
15
+ '# This file has been unencrypted for you to edit it.',
16
+ '# Make your changes and close your edit to re-encrypt and save the file.',
17
+ '# You can change the apiVersion or add any additional metadata.',
18
+ '#',
19
+ '# You should not change the kind of document, it should be HippoEncryptedSecret.'
20
+ ].join("\n")
21
+
21
22
  def initialize(manager, name)
22
23
  @manager = manager
23
24
  @name = name
24
25
  end
25
26
 
26
- # Return the path to the stored encrypted secret file
27
+ # Return the path where this secret is stored
27
28
  #
28
29
  # @return [String]
29
30
  def path
30
- File.join(@manager.recipe.root, 'secrets', @manager.stage.name, "#{@name}.yaml")
31
+ File.join(@manager.root, "#{@name}.yaml")
31
32
  end
32
33
 
33
- # Does the secret file currently exist on the file system?
34
+ # Does this secret exist yet?
34
35
  #
35
36
  # @return [Boolean]
36
37
  def exists?
37
38
  File.file?(path)
38
39
  end
39
40
 
40
- # Return the secret file as it should be applied to Kubernetes.
41
+ # Create a new empty secret file on the file system
41
42
  #
42
- # @return [String]
43
- def to_secret_yaml
44
- decrypted_parts.map do |part, _array|
45
- part['kind'] = 'Secret'
46
- part['metadata'] ||= {}
47
- part['metadata']['namespace'] = @manager.stage.namespace
48
-
49
- part['data'].each do |key, value|
50
- part['data'][key] = Base64.encode64(value).gsub("\n", '').strip
51
- end
52
- part
53
- end.map { |p| p.hash.to_yaml } .join("---\n")
43
+ # @return [void]
44
+ def create
45
+ return if exists?
46
+
47
+ od = ObjectDefinition.new(
48
+ {
49
+ 'kind' => 'HippoEncryptedSecret',
50
+ 'apiVersion' => 'v1',
51
+ 'metadata' => {
52
+ 'name' => @name
53
+ },
54
+ 'data' => {
55
+ 'example-value' => @manager.encrypt('This is an example encrypted value!')
56
+ }
57
+ },
58
+ @manager.stage,
59
+ clean: true
60
+ )
61
+ File.open(path, 'w') { |f| f.write(HEADER + "\n" + od.yaml) }
54
62
  end
55
63
 
56
- # Return the secret file as it should be displayed for editting
64
+ # Read the value from the file and decrypt all values that are present
57
65
  #
58
66
  # @return [String]
59
- def to_editable_yaml
60
- decrypted_parts.map { |p| p.hash.to_yaml } .join("---\n")
61
- end
62
-
63
- # Edit a secret file by opening an editor and allow changes to be made.
64
- # When the editor completes, finish by writing the file back to the disk.
65
- #
66
- # @return [void]
67
- def edit
67
+ def editable_yaml
68
68
  return unless exists?
69
69
 
70
- # Obtain a list of parts and map them to the name of the secret
71
- # in the file.
72
- original_decrypted_part_data = parse_parts(load_yaml_from_path(path), :decrypt)
73
- original_encrypted_part_data = load_yaml_from_path(path).each_with_object({}) do |part, hash|
74
- next if part.nil? || part.empty?
75
-
76
- hash[part.dig('metadata', 'name')] = part['data']
77
- end
78
- original_part_data = original_decrypted_part_data.each_with_object({}) do |part, hash|
79
- name = part.dig('metadata', 'name')
80
- enc = original_encrypted_part_data[name]
81
- next if enc.nil?
82
-
83
- hash[part.dig('metadata', 'name')] = part['data'].each_with_object({}) do |(key, value), hash2|
84
- hash2[key] = [value, enc[key]]
70
+ objects = Util.load_yaml_from_file(path)
71
+ objects.each do |hash|
72
+ hash['data'].each do |key, value|
73
+ hash['data'][key] = @manager.decrypt(value)
85
74
  end
86
75
  end
87
76
 
88
- # Open the editor and gather what the user provides.
89
- saved_contents = open_in_editor("secret-#{@name}", to_editable_yaml)
90
-
91
- # This saved contents should now be validated to ensure it is valid
92
- # YAML and, if so, it should be encrypted and then saved into the
93
- # secret file as needed.
94
- begin
95
- yaml_parts = load_yaml_from_data(saved_contents)
96
- parts = parse_parts(yaml_parts, :encrypt, original_part_data)
97
- write(parts)
98
- rescue StandardError => e
99
- raise
100
- puts "An error occurred parsing your file: #{e.message}"
101
- saved_contents = open_in_editor("secret-#{@name}", saved_contents)
102
- retry
103
- end
104
-
105
- puts "#{@name} secret has been editted"
77
+ objects.map(&:to_yaml).join("\n---\n")
106
78
  end
107
79
 
108
- # Create a new templated encrypted secret with the given name
80
+ # Edit this secret
109
81
  #
110
82
  # @return [void]
111
- def create
112
- template = {
113
- 'apiVersion' => 'v1',
114
- 'kind' => 'HippoEncryptedSecret',
115
- 'metadata' => {
116
- 'name' => @name
117
- },
118
- 'data' => {
119
- 'example' => @manager.encrypt('This is an example secret!')
120
- }
121
- }
122
- write([template])
123
- end
124
-
125
- private
126
-
127
- def decrypted_parts
128
- return unless exists?
129
-
130
- yaml_parts = load_yaml_from_path(path)
131
- parse_parts(yaml_parts, :decrypt)
132
- end
133
-
134
- def parse_parts(yaml_parts, method, skips = {})
135
- yaml_parts.each_with_object([]) do |part, array|
136
- next if part.hash.nil? || part.hash.empty?
137
-
138
- part['data'].each do |key, value|
139
- skip = skips[part.dig('metadata', 'name')]
140
- part['data'][key] = if skip && skip[key] && skip[key][0] == value
141
- skip[key][1]
142
- else
143
- @manager.public_send(method, value.to_s)
144
- end
83
+ def edit
84
+ contents = Util.open_in_editor("secret-#{@name}", EDIT_HEADER + "\n" + editable_yaml)
85
+ yamls = Util.load_yaml_from_data(contents)
86
+ ods = Util.create_object_definitions({ 'secret' => yamls }, @manager.stage, required_kinds: ['HippoEncryptedSecret'], clean: true)
87
+ ods.each do |od|
88
+ od['data'].each do |key, value|
89
+ od['data'][key] = @manager.encrypt(value)
145
90
  end
146
-
147
- array << part
148
91
  end
92
+ File.open(path, 'w') { |f| f.write(HEADER + "\n" + ods.map(&:yaml).join("\n---\n")) }
93
+ rescue StandardError => e
94
+ puts "Failed to edit secret (#{e.message})"
95
+ retry
149
96
  end
150
97
 
151
- # Write the array of given parts into a file along with a suitable
152
- # explanatory header.
98
+ # Return this secret as it can be exported to kubernetes
153
99
  #
154
- # @return [void]
155
- def write(parts)
156
- parts = parts.map(&:to_yaml).join("---\n")
157
- data_to_write = HEADER + "\n" + parts
158
-
159
- FileUtils.mkdir_p(File.dirname(path))
160
- File.open(path, 'w') do |f|
161
- f.write(data_to_write)
100
+ # @return [Array<ObjectDefinition>]
101
+ def applyable_yaml
102
+ objects = Util.load_yaml_from_file(path)
103
+ objects = objects.each do |hash|
104
+ hash['kind'] = 'Secret'
105
+ hash['data'].each do |key, value|
106
+ hash['data'][key] = Base64.encode64(@manager.decrypt(value)).gsub("\n", '')
107
+ end
162
108
  end
109
+ Util.create_object_definitions({ 'secret' => objects }, @manager.stage)
163
110
  end
164
111
  end
165
112
  end