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.
@@ -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