hippo-cli 1.0.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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'git'
5
+ require 'hippo/error'
6
+
7
+ module Hippo
8
+ class Repository
9
+ def initialize(options)
10
+ @options = options
11
+ end
12
+
13
+ def url
14
+ @options['url']
15
+ end
16
+
17
+ # Return the path where this repository is stored on the local
18
+ # computer.
19
+ #
20
+ # @return [String]
21
+ def path
22
+ return @options['path'] if @options['path']
23
+
24
+ @path ||= begin
25
+ digest = Digest::SHA256.hexdigest(url)
26
+ File.join('', 'tmp', 'hippo-repos', digest)
27
+ end
28
+ end
29
+
30
+ # Clone this repository into the working directory for this
31
+ # application.
32
+ #
33
+ # @return [Boolean]
34
+ def clone
35
+ if File.directory?(path)
36
+ raise RepositoryAlreadyClonedError, "Repository has already been cloned to #{path}. Maybe you just want to pull?"
37
+ end
38
+
39
+ @git = Git.clone(url, path)
40
+ true
41
+ rescue Git::GitExecuteError => e
42
+ raise RepositoryCloneError, e.message
43
+ end
44
+
45
+ # Has this been cloned?
46
+ #
47
+ # @return [Boolean]
48
+ def cloned?
49
+ File.directory?(path)
50
+ end
51
+
52
+ # Fetch the latest copy of this repository
53
+ #
54
+ # @return [Boolean]
55
+ def fetch
56
+ git.fetch
57
+ true
58
+ rescue Git::GitExecuteError => e
59
+ raise RepositoryFetchError, e.message
60
+ end
61
+
62
+ # Checkout the version of the application for the given commit or
63
+ # branch name in the local copy.
64
+ #
65
+ # @param ref [String]
66
+ # @return [Boolean]
67
+ def checkout(ref)
68
+ git.checkout("origin/#{ref}")
69
+ true
70
+ rescue Git::GitExecuteError => e
71
+ if e.message =~ /did not match any file\(s\) known to git/
72
+ raise RepositoryCheckoutError, "No branch named '#{ref}' found in repository"
73
+ else
74
+ raise RepositoryCheckoutError, e.message
75
+ end
76
+ end
77
+
78
+ # Return the commit reference for the currently checked out branch
79
+ #
80
+ # @return [String]
81
+ def commit
82
+ git.log(1).first
83
+ end
84
+
85
+ # Get the commit reference for the given branch on the remote
86
+ # repository by asking it directly.
87
+ #
88
+ # @param name [String]
89
+ # @return [Git::Commit]
90
+ def commit_for_branch(branch)
91
+ git.object("origin/#{branch}").log(1).first
92
+ rescue Git::GitExecuteError => e
93
+ if e.message =~ /Not a valid object name/
94
+ raise Error, "'#{branch}' is not a valid branch name in repository"
95
+ else
96
+ raise
97
+ end
98
+ end
99
+
100
+ # Return the template variables for a repository
101
+ #
102
+ # @return [Hash]
103
+ def template_vars
104
+ {
105
+ 'url' => url,
106
+ 'path' => path
107
+ }
108
+ end
109
+
110
+ private
111
+
112
+ def git
113
+ @git ||= begin
114
+ if cloned?
115
+ Git.open(path)
116
+ else
117
+ raise Error, 'Could not create a git instance because there is no repository cloned for it.'
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'openssl'
5
+ require 'encryptor'
6
+ require 'hippo/util'
7
+
8
+ module Hippo
9
+ class Secret
10
+ include Hippo::Util
11
+
12
+ HEADER = [
13
+ '# This file is encrypted and managed by Hippo.',
14
+ '# Use `hippo secret [stage] [name]` to make changes to it.',
15
+ '#',
16
+ '# Note: this cannot be applied directly to your Kubernetes server because',
17
+ '# HippoEncryptedSecret is not a valid object. It will be automatically ',
18
+ '# converted to a Secret when it is applied by Hippo.'
19
+ ].join("\n")
20
+
21
+ def initialize(manager, name)
22
+ @manager = manager
23
+ @name = name
24
+ end
25
+
26
+ # Return the path to the stored encrypted secret file
27
+ #
28
+ # @return [String]
29
+ def path
30
+ File.join(@manager.recipe.root, 'secrets', @manager.stage.name, "#{@name}.yaml")
31
+ end
32
+
33
+ # Does the secret file currently exist on the file system?
34
+ #
35
+ # @return [Boolean]
36
+ def exists?
37
+ File.file?(path)
38
+ end
39
+
40
+ # Return the secret file as it should be applied to Kubernetes.
41
+ #
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")
54
+ end
55
+
56
+ # Return the secret file as it should be displayed for editting
57
+ #
58
+ # @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
68
+ return unless exists?
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]]
85
+ end
86
+ end
87
+
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"
106
+ end
107
+
108
+ # Create a new templated encrypted secret with the given name
109
+ #
110
+ # @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
145
+ end
146
+
147
+ array << part
148
+ end
149
+ end
150
+
151
+ # Write the array of given parts into a file along with a suitable
152
+ # explanatory header.
153
+ #
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)
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'hippo/secret'
5
+
6
+ module Hippo
7
+ class SecretManager
8
+ attr_reader :recipe
9
+ attr_reader :stage
10
+ attr_reader :key
11
+
12
+ CIPHER = OpenSSL::Cipher.new('aes-256-gcm')
13
+
14
+ def initialize(recipe, stage)
15
+ @recipe = recipe
16
+ @stage = stage
17
+ end
18
+
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
26
+
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(object.to_yaml)
38
+ @key = secret_key
39
+ end
40
+
41
+ # Download the current key from the Kubernetes API and set it as the
42
+ # key for this instance
43
+ #
44
+ # @return [void]
45
+ def download_key
46
+ return if @key
47
+
48
+ value = @recipe.kubernetes.get_with_kubectl(@stage, 'secret', 'hippo-secret-key').first
49
+ return if value.nil?
50
+ return if value.dig('data', 'key').nil?
51
+
52
+ @key = Base64.decode64(Base64.decode64(value['data']['key']))
53
+ rescue Hippo::Error => e
54
+ raise unless e.message =~ /not found/
55
+ end
56
+
57
+ def key_available?
58
+ download_key
59
+ !@key.nil?
60
+ end
61
+
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/, ''))
69
+ end
70
+ end
71
+
72
+ def encrypt(value)
73
+ CIPHER.encrypt
74
+ iv = CIPHER.random_iv
75
+ salt = SecureRandom.random_bytes(16)
76
+ encrypted_value = Encryptor.encrypt(value: value.to_s, key: @key, iv: iv, salt: salt)
77
+ 'encrypted:' + Base64.encode64([
78
+ Base64.encode64(encrypted_value),
79
+ Base64.encode64(salt),
80
+ Base64.encode64(iv)
81
+ ].join('---')).gsub("\n", '')
82
+ end
83
+
84
+ # Decrypt the given value value and return it
85
+ #
86
+ # @param value [String]
87
+ # @return [String]
88
+ def decrypt(value)
89
+ value = value.to_s
90
+ if value =~ /\Aencrypted:(.*)/
91
+ value = Base64.decode64(Regexp.last_match(1))
92
+ encrypted_value, salt, iv = value.split('---', 3).map { |s| Base64.decode64(s) }
93
+ Encryptor.decrypt(value: encrypted_value, key: @key, iv: iv, salt: salt).to_s
94
+ else
95
+ value
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hippo
4
+ class Stage
5
+ def initialize(options)
6
+ @options = options
7
+ end
8
+
9
+ def name
10
+ @options['name']
11
+ end
12
+
13
+ def branch
14
+ @options['branch']
15
+ end
16
+
17
+ def namespace
18
+ @options['namespace']
19
+ end
20
+
21
+ def template_vars
22
+ {
23
+ 'name' => name,
24
+ 'branch' => branch,
25
+ 'namespace' => namespace,
26
+ 'vars' => @options['vars'] || {}
27
+ }
28
+ end
29
+
30
+ def kubectl(*command)
31
+ "kubectl -n #{namespace} #{command.join(' ')}"
32
+ end
33
+ end
34
+ end
data/lib/hippo/util.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hippo/error'
4
+ require 'hippo/yaml_part'
5
+
6
+ module Hippo
7
+ module Util
8
+ def load_yaml_from_path(path)
9
+ return nil if path.nil?
10
+ return nil unless File.file?(path)
11
+
12
+ data = File.read(path)
13
+ load_yaml_from_data(data, path)
14
+ end
15
+
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
+ end
21
+ end
22
+
23
+ def load_yaml_from_directory(path)
24
+ return [] if path.nil?
25
+ return [] unless File.directory?(path)
26
+
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?
30
+
31
+ array << yaml
32
+ end.flatten
33
+ end
34
+
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
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,3 @@
1
+ module Hippo
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hippo
4
+ class YAMLPart
5
+ attr_reader :yaml
6
+
7
+ def initialize(yaml, path, index)
8
+ @yaml = yaml.strip
9
+ @path = path
10
+ @index = index
11
+ end
12
+
13
+ def hash
14
+ @hash ||= YAML.safe_load(@yaml)
15
+ rescue Psych::SyntaxError => e
16
+ raise Error, "YAML parsing error in #{@path} (index #{@index}) (#{e.message})"
17
+ end
18
+
19
+ def empty?
20
+ @yaml.nil? ||
21
+ @yaml.empty? ||
22
+ hash.nil? ||
23
+ hash.empty?
24
+ end
25
+
26
+ def to_yaml
27
+ hash.to_yaml
28
+ end
29
+
30
+ def dig(*args)
31
+ hash.dig(*args)
32
+ end
33
+
34
+ def [](name)
35
+ hash[name]
36
+ end
37
+
38
+ def []=(name, value)
39
+ hash[name] = value
40
+ end
41
+
42
+ def parse(recipe, stage, commit)
43
+ parsed_part = recipe.parse(stage, commit, @yaml)
44
+ self.class.new(parsed_part, @path, @index)
45
+ end
46
+ end
47
+ end
data/lib/hippo.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hippo
4
+ def self.root
5
+ File.expand_path('../', __dir__)
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ # Welcome to your Hippofile 🦛
2
+ #
3
+ # In here you configure how you wish to build, publish and
4
+ # deploy your application using Docker & Kubernetes.
5
+
6
+ # Where is the application that you wish to deploy hosted?
7
+ repository:
8
+ url: git@github.com:username/repo
9
+
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
@@ -0,0 +1,7 @@
1
+ kind: ConfigMap
2
+ apiVersion: v1
3
+ metadata:
4
+ name: env-vars
5
+ data:
6
+ RAILS_ENV: production
7
+ RACK_TRUSTED_PROXIES: 127.0.0.1 ::1 10.217.0.0/16
@@ -0,0 +1,29 @@
1
+ kind: Deployment
2
+ apiVersion: apps/v1
3
+ metadata:
4
+ name: web
5
+ spec:
6
+ selector:
7
+ matchLabels:
8
+ process_type: web
9
+ replicas: 1
10
+ template:
11
+ metadata:
12
+ labels:
13
+ process_type: web
14
+ spec:
15
+ containers:
16
+ - name: myapp
17
+ image: "{{ builds.app.image-name }}:{{ commit.ref }}"
18
+ imagePullPolicy: Always
19
+ command:
20
+ - bundle
21
+ - exec
22
+ - puma
23
+ - -C
24
+ - config/puma.rb
25
+ envFrom:
26
+ - configMapRef:
27
+ name: env-vars
28
+ ports:
29
+ - containerPort: 3000
@@ -0,0 +1,26 @@
1
+ kind: Deployment
2
+ apiVersion: apps/v1
3
+ metadata:
4
+ name: worker
5
+ spec:
6
+ selector:
7
+ matchLabels:
8
+ app: worker
9
+ replicas: 1
10
+ template:
11
+ metadata:
12
+ labels:
13
+ app: worker
14
+ spec:
15
+ containers:
16
+ - name: myapp
17
+ image: "{{ builds.app.image-name }}:{{ commit.ref }}"
18
+ imagePullPolicy: Always
19
+ command:
20
+ - bundle
21
+ - exec
22
+ - rake
23
+ - jobs:work
24
+ envFrom:
25
+ - configMapRef:
26
+ name: env-vars
@@ -0,0 +1,22 @@
1
+ kind: Job
2
+ apiVersion: batch/v1
3
+ metadata:
4
+ name: load-schema
5
+ spec:
6
+ backoffLimit: 0
7
+ ttlSecondsAfterFinished: 300
8
+ template:
9
+ spec:
10
+ restartPolicy: Never
11
+ containers:
12
+ - name: myapp
13
+ image: "{{ builds.app.image-name }}:{{ commit.ref }}"
14
+ imagePullPolicy: Always
15
+ command:
16
+ - bundle
17
+ - exec
18
+ - rake
19
+ - db:schema:load
20
+ envFrom:
21
+ - configMapRef:
22
+ name: env-vars
@@ -0,0 +1,22 @@
1
+ kind: Job
2
+ apiVersion: batch/v1
3
+ metadata:
4
+ name: migration
5
+ spec:
6
+ backoffLimit: 0
7
+ ttlSecondsAfterFinished: 60
8
+ template:
9
+ spec:
10
+ restartPolicy: Never
11
+ containers:
12
+ - name: myapp
13
+ image: "{{ builds.app.image-name }}:{{ commit.ref }}"
14
+ imagePullPolicy: Always
15
+ command:
16
+ - bundle
17
+ - exec
18
+ - rake
19
+ - db:migrate
20
+ envFrom:
21
+ - configMapRef:
22
+ name: env-vars
@@ -0,0 +1,13 @@
1
+ kind: Ingress
2
+ apiVersion: networking.k8s.io/v1beta1
3
+ metadata:
4
+ name: myapp
5
+ spec:
6
+ rules:
7
+ - host: "{{ stage.vars.hostname }}"
8
+ http:
9
+ paths:
10
+ - path: /
11
+ backend:
12
+ serviceName: web
13
+ servicePort: 80
@@ -0,0 +1,11 @@
1
+ kind: Service
2
+ apiVersion: v1
3
+ metadata:
4
+ name: web
5
+ spec:
6
+ type: ClusterIP
7
+ ports:
8
+ - port: 80
9
+ targetPort: 3000
10
+ selector:
11
+ process_type: web
@@ -0,0 +1,6 @@
1
+ name: production
2
+ branch: master
3
+ namespace: myapp-production
4
+ vars:
5
+ example: Hello world!
6
+ hostname: myapp.mydomain.com
data.tar.gz.sig ADDED
Binary file