hippo-cli 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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