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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -0
- data/cli/apply_config.rb +4 -5
- data/cli/apply_services.rb +3 -3
- data/cli/console.rb +6 -6
- data/cli/deploy.rb +11 -22
- data/cli/install.rb +22 -23
- data/cli/kubectl.rb +3 -3
- data/cli/secrets_edit.rb +34 -0
- data/cli/secrets_key.rb +20 -0
- data/lib/hippo.rb +19 -0
- data/lib/hippo/cli.rb +210 -0
- data/lib/hippo/deployment_monitor.rb +82 -0
- data/lib/hippo/error.rb +0 -12
- data/lib/hippo/image.rb +74 -0
- data/lib/hippo/manifest.rb +81 -0
- data/lib/hippo/object_definition.rb +63 -0
- data/lib/hippo/secret.rb +61 -114
- data/lib/hippo/secret_manager.rb +43 -32
- data/lib/hippo/stage.rb +157 -7
- data/lib/hippo/util.rb +56 -33
- data/lib/hippo/version.rb +1 -1
- data/template/Hippofile +12 -13
- data/template/config/{production/env-vars.yaml → env-vars.yaml} +0 -0
- data/template/jobs/{upgrade → deploy}/db-migration.yaml +0 -0
- data/template/stages/production.yaml +1 -0
- metadata +31 -16
- metadata.gz.sig +0 -0
- data/cli/build.rb +0 -16
- data/cli/edit-secret.rb +0 -45
- data/cli/objects.rb +0 -34
- data/cli/publish.rb +0 -17
- data/cli/secrets.rb +0 -23
- data/cli/status.rb +0 -15
- data/lib/hippo/build_spec.rb +0 -32
- data/lib/hippo/cli_steps.rb +0 -315
- data/lib/hippo/kubernetes.rb +0 -200
- data/lib/hippo/recipe.rb +0 -126
- data/lib/hippo/repository.rb +0 -122
- data/lib/hippo/yaml_part.rb +0 -47
@@ -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
|
data/lib/hippo/error.rb
CHANGED
@@ -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
|
data/lib/hippo/image.rb
ADDED
@@ -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
|
data/lib/hippo/secret.rb
CHANGED
@@ -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
|
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
|
27
|
+
# Return the path where this secret is stored
|
27
28
|
#
|
28
29
|
# @return [String]
|
29
30
|
def path
|
30
|
-
File.join(@manager.
|
31
|
+
File.join(@manager.root, "#{@name}.yaml")
|
31
32
|
end
|
32
33
|
|
33
|
-
# Does
|
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
|
-
#
|
41
|
+
# Create a new empty secret file on the file system
|
41
42
|
#
|
42
|
-
# @return [
|
43
|
-
def
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
-
#
|
64
|
+
# Read the value from the file and decrypt all values that are present
|
57
65
|
#
|
58
66
|
# @return [String]
|
59
|
-
def
|
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
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
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
|
-
|
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
|
-
#
|
80
|
+
# Edit this secret
|
109
81
|
#
|
110
82
|
# @return [void]
|
111
|
-
def
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
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
|
-
#
|
152
|
-
# explanatory header.
|
98
|
+
# Return this secret as it can be exported to kubernetes
|
153
99
|
#
|
154
|
-
# @return [
|
155
|
-
def
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
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
|