hippo-cli 1.1.1 → 1.2.3

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.
@@ -4,16 +4,23 @@ require 'liquid'
4
4
  require 'open3'
5
5
  require 'hippo/secret_manager'
6
6
  require 'hippo/package'
7
+ require 'hippo/liquid_filters'
7
8
 
8
9
  module Hippo
9
10
  class Stage
10
- attr_reader :manifest
11
+ attr_reader :wd
12
+ attr_reader :config_root
11
13
 
12
- def initialize(manifest, options)
13
- @manifest = manifest
14
+ def initialize(wd, config_root, options)
15
+ @wd = wd
16
+ @config_root = config_root
14
17
  @options = options
15
18
  end
16
19
 
20
+ def manifest
21
+ wd.manifest
22
+ end
23
+
17
24
  def name
18
25
  @options['name']
19
26
  end
@@ -38,8 +45,18 @@ module Hippo
38
45
  @options['config']
39
46
  end
40
47
 
48
+ def command(name)
49
+ base = manifest.commands[name]
50
+ return nil if base.nil?
51
+
52
+ {
53
+ target: base['target'],
54
+ command: decorator.call(base['command'])
55
+ }
56
+ end
57
+
41
58
  def images
42
- @images ||= @manifest.images.deep_merge(@options['images'] || {}).each_with_object({}) do |(key, image), hash|
59
+ @images ||= manifest.images.deep_merge(@options['images'] || {}).each_with_object({}) do |(key, image), hash|
43
60
  hash[key] = Image.new(key, image)
44
61
  end
45
62
  end
@@ -48,14 +65,14 @@ module Hippo
48
65
  def template_vars
49
66
  @template_vars ||= begin
50
67
  {
51
- 'manifest' => @manifest.template_vars,
68
+ 'manifest' => manifest.template_vars,
52
69
  'stage-name' => name,
53
70
  'branch' => branch,
54
71
  'image-tag' => image_tag,
55
72
  'namespace' => namespace,
56
73
  'context' => context,
57
74
  'images' => images.values.each_with_object({}) { |image, hash| hash[image.name] = image.template_vars },
58
- 'config' => @manifest.config.deep_merge(config),
75
+ 'config' => manifest.config.deep_merge(config),
59
76
  'secrets' => secret_manager.all
60
77
  }
61
78
  end
@@ -67,15 +84,51 @@ module Hippo
67
84
  proc do |data|
68
85
  begin
69
86
  template = Liquid::Template.parse(data)
70
- template.render(template_vars)
87
+ template.render(template_vars, filters: [LiquidFilters])
71
88
  rescue Liquid::SyntaxError => e
72
89
  raise Error, "Template error: #{e.message}"
73
90
  end
74
91
  end
75
92
  end
76
93
 
94
+ def readme
95
+ return unless manifest.readme
96
+
97
+ decorator.call(manifest.readme)
98
+ end
99
+
100
+ # Return an array of objects that currently exist on the kubernetesa
101
+ # API.
102
+ #
103
+ # @return [Array<Hash>]
104
+ def live_objects(pruneable_only: false)
105
+ los = get(all_objects.keys.join(','), '--selector', 'app.kubernetes.io/managed-by=hippo')
106
+ los.each_with_object([]) do |live_obj, array|
107
+ local = all_objects.dig(live_obj.kind, live_obj.name)
108
+ pruneable = local.nil? && (live_obj.kind != 'Secret' && live_obj.name != 'hippo-secret-key')
109
+
110
+ next if pruneable_only && !pruneable
111
+
112
+ array << {
113
+ live: live_obj,
114
+ local: local,
115
+ pruneable: pruneable
116
+ }
117
+ end
118
+ end
119
+
120
+ # Remove any objects which are prunable
121
+ #
122
+ # @return [void]
123
+ def delete_pruneable_objects
124
+ live_objects(pruneable_only: true).each do |object|
125
+ object = object[:live]
126
+ delete(object.kind, object.name)
127
+ end
128
+ end
129
+
77
130
  def objects(path)
78
- @manifest.objects(path, decorator: decorator)
131
+ manifest.objects(path, decorator: decorator)
79
132
  end
80
133
 
81
134
  def secret_manager
@@ -111,6 +164,19 @@ module Hippo
111
164
  @jobs[type] ||= Util.create_object_definitions(objects("jobs/#{type}"), self)
112
165
  end
113
166
 
167
+ # Return an array of all objects that should be managed by Hippo
168
+ #
169
+ # @return [Hash]
170
+ def all_objects
171
+ @all_objects ||= begin
172
+ all = (deployments | services | configs | jobs('install') | jobs('deploy'))
173
+ all.each_with_object({}) do |object, hash|
174
+ hash[object.kind] ||= {}
175
+ hash[object.kind][object.name] = object
176
+ end
177
+ end
178
+ end
179
+
114
180
  # Return a hash of all packages available in the stage
115
181
  #
116
182
  # @return [Hash<String, Hippo::Package>]
@@ -144,33 +210,17 @@ module Hippo
144
210
  # @param objects [Array<Hippo::ObjectDefinition>]
145
211
  # @return [Hash]
146
212
  def apply(objects)
147
- yaml_to_apply = objects.map(&:yaml_to_apply).join("\n")
148
-
149
213
  command = ['kubectl']
150
214
  command += ['--context', context] if context
151
215
  command += ['apply', '-f', '-']
152
- Open3.popen3(command.join(' ')) do |stdin, stdout, stderr, wt|
153
- stdin.puts yaml_to_apply
154
- stdin.close
155
216
 
156
- stdout = stdout.read.strip
157
- stderr = stderr.read.strip
217
+ yaml_to_apply = objects.map(&:yaml_to_apply).join("\n")
158
218
 
159
- if wt.value.success?
160
- stdout.split("\n").each_with_object({}) do |line, hash|
161
- next unless line =~ %r{\A([\w\/\-\.]+) (\w+)\z}
219
+ stdout, stderr, status = Open3.capture3(command.join(' '), stdin_data: yaml_to_apply + "\n")
162
220
 
163
- object = Regexp.last_match(1)
164
- status = Regexp.last_match(2)
165
- hash[object] = status
221
+ raise Error, "[kubectl] #{stderr}" unless status.success?
166
222
 
167
- status = "\e[32m#{status}\e[0m" unless status == 'unchanged'
168
- puts "\e[37m====> #{object} #{status}\e[0m"
169
- end
170
- else
171
- raise Error, "[kubectl] #{stderr}"
172
- end
173
- end
223
+ Util.parse_kubectl_apply_lines(stdout)
174
224
  end
175
225
 
176
226
  # Get some data from the kubernetes API
@@ -193,19 +243,15 @@ module Hippo
193
243
  # @return [Boolean]
194
244
  def delete(*names)
195
245
  command = kubectl('delete', *names)
196
- Open3.popen3(*command) do |_, stdout, stderr, wt|
197
- if wt.value.success?
198
- stdout.read.split("\n").each do |line|
199
- puts "\e[37m====> #{line}\e[0m"
200
- end
201
- true
246
+ stdout, stderr, status = Open3.capture3(*command)
247
+ if status.success?
248
+ Util.parse_kubectl_apply_lines(stdout)
249
+ true
250
+ else
251
+ if stderr =~ /\" not found$/
252
+ false
202
253
  else
203
- stderr = stderr.read
204
- if stderr =~ /\" not found$/
205
- false
206
- else
207
- raise Error, "[kutectl] #{stderr}"
208
- end
254
+ raise Error, "[kutectl] #{stderr}"
209
255
  end
210
256
  end
211
257
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require 'open3'
4
5
  require 'hippo/error'
5
6
  require 'hippo/object_definition'
6
7
 
@@ -30,6 +31,14 @@ module Hippo
30
31
  end
31
32
  end
32
33
 
34
+ def load_objects_from_path(path, decorator: nil)
35
+ files = Dir[path]
36
+ files.each_with_object({}) do |path, objects|
37
+ file = load_yaml_from_file(path, decorator: decorator)
38
+ objects[path] = file
39
+ end
40
+ end
41
+
33
42
  def create_object_definitions(hash, stage, required_kinds: nil, clean: false)
34
43
  index = 0
35
44
  hash.each_with_object([]) do |(path, objects), array|
@@ -55,13 +64,17 @@ module Hippo
55
64
  end
56
65
 
57
66
  def open_in_editor(name, contents)
67
+ if ENV['EDITOR'].nil?
68
+ raise Error, 'No EDITOR environment variable has been defined'
69
+ end
70
+
58
71
  tmp_root = File.join(ENV['HOME'], '.hippo')
59
72
  FileUtils.mkdir_p(tmp_root)
60
73
  begin
61
74
  tmpfile = Tempfile.new([name, '.yaml'], tmp_root)
62
75
  tmpfile.write(contents)
63
76
  tmpfile.close
64
- system("#{ENV['EDITOR']} #{tmpfile.path}")
77
+ Kernel.system("#{ENV['EDITOR']} #{tmpfile.path}")
65
78
  tmpfile.open
66
79
  tmpfile.read
67
80
  ensure
@@ -102,6 +115,53 @@ module Hippo
102
115
  response = response.to_s.strip
103
116
  response.empty? ? default : response
104
117
  end
118
+
119
+ def system(command, stdin_data: nil)
120
+ stdout, stderr, status = Open3.capture3(command, stdin_data: stdin_data)
121
+ unless status.success?
122
+ raise Error, "Command failed to execute: #{stderr}"
123
+ end
124
+
125
+ stdout
126
+ end
127
+
128
+ def action(message)
129
+ $stdout.print message
130
+ complete_state = 'done'
131
+ color = '32'
132
+ passed_proc = proc do |value|
133
+ complete_state = value
134
+ color = '33'
135
+ end
136
+ return_value = yield(passed_proc)
137
+ puts " \e[#{color}m#{complete_state}\e[0m"
138
+ return_value
139
+ rescue StandardError => e
140
+ puts " \e[31merror\e[0m"
141
+ raise
142
+ end
143
+ end
144
+
145
+ def self.parse_kubectl_apply_lines(stdout)
146
+ stdout.split("\n").each_with_object({}) do |line, hash|
147
+ if line =~ %r{\A([\w\/\-\.]+) (\w+)\z}
148
+ object = Regexp.last_match(1)
149
+ status = Regexp.last_match(2)
150
+ elsif line =~ %r{\A[\w\.\/\-]+ \"([\w\-]+)\" (\w+)\z}
151
+ object = Regexp.last_match(1)
152
+ status = Regexp.last_match(2)
153
+ else
154
+ next
155
+ end
156
+ hash[object] = status
157
+
158
+ color = '32'
159
+ color = '31' if status == 'deleted'
160
+ color = '33' if status == 'configured'
161
+
162
+ status = "\e[#{color}m#{status}\e[0m" unless status == 'unchanged'
163
+ puts "\e[37m====> #{object} #{status}\e[0m"
164
+ end
105
165
  end
106
166
  end
107
167
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hippo
4
- VERSION = '1.1.1'
4
+ VERSION = '1.2.3'
5
5
  end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hippo/manifest'
4
+ require 'hippo/util'
5
+ require 'hippo/error'
6
+
7
+ module Hippo
8
+ class WorkingDirectory
9
+ attr_reader :root
10
+
11
+ def initialize(root = FileUtils.pwd)
12
+ @root = root
13
+ end
14
+
15
+ # Return the path to the config file in this working directory
16
+ #
17
+ # @return [String]
18
+ def config_path
19
+ File.join(@root, 'manifest.yaml')
20
+ end
21
+
22
+ # Return the path to the local config file
23
+ #
24
+ # @return [String]
25
+ def local_config_path
26
+ File.join(@root, 'manifest.local.yaml')
27
+ end
28
+
29
+ # Return all the options configured in this working directory
30
+ #
31
+ # @return [Hash]
32
+ def options
33
+ return @options if @options
34
+
35
+ if File.file?(config_path)
36
+ @options = YAML.load_file(config_path)
37
+ if File.file?(local_config_path)
38
+ @options = @options.deep_merge(YAML.load_file(local_config_path))
39
+ end
40
+ @options
41
+ else
42
+ raise Error, "No manifest config file found at #{config_path}"
43
+ end
44
+ end
45
+
46
+ # Return the manifest objet for this working directory
47
+ #
48
+ # @return [Hippo::Manifest]
49
+ def manifest(update: true)
50
+ if update && !@updated_manifest
51
+ update_from_remote if can_update?
52
+ @updated_manifest = true
53
+ end
54
+
55
+ raise Error, 'No manifest path could be determined' if manifest_path.nil?
56
+
57
+ @manifest ||= Manifest.load_from_file(File.join(manifest_path, 'Hippofile'))
58
+ end
59
+
60
+ # Return the path to the manifest directory
61
+ #
62
+ # @return [String]
63
+ def manifest_path
64
+ case source_type
65
+ when 'local'
66
+ options.dig('source', 'localOptions', 'path')
67
+ when 'remote'
68
+ path = [remote_path] if remote_path
69
+ File.join(remote_root_path, *path)
70
+ else
71
+ raise Error, "Invalid source.type ('#{source_type}')"
72
+ end
73
+ end
74
+
75
+ # Return the type of manifest
76
+ #
77
+ # @return [String]
78
+ def source_type
79
+ options.dig('source', 'type')
80
+ end
81
+
82
+ # Return the path on the local filesystem that the remote repository
83
+ # should be stored in.
84
+ #
85
+ # @return [String]
86
+ def remote_root_path
87
+ repo_ref = Digest::SHA1.hexdigest([remote_repository, remote_branch].join('---'))
88
+ File.join(Hippo.tmp_root, 'manifests', repo_ref)
89
+ end
90
+
91
+ # Return the branch to use from the remote repository
92
+ #
93
+ # @return [String]
94
+ def remote_branch
95
+ options.dig('source', 'remoteOptions', 'branch') || 'master'
96
+ end
97
+
98
+ # Return the URL to the remote repository
99
+ #
100
+ # @return [String]
101
+ def remote_repository
102
+ options.dig('source', 'remoteOptions', 'repository')
103
+ end
104
+
105
+ # Return the path within the remote repository that we wish to work
106
+ # with.
107
+ #
108
+ # @return [String]
109
+ def remote_path
110
+ options.dig('source', 'remoteOptions', 'path')
111
+ end
112
+
113
+ # Update the local cached copy of the manifest from the remote
114
+ #
115
+ # @return [Boolean]
116
+ def update_from_remote(verbose: false)
117
+ return false unless source_type == 'remote'
118
+
119
+ Util.action "Updating manifest from #{remote_repository}..." do
120
+ if File.directory?(remote_root_path)
121
+ Util.system("git -C #{remote_root_path} fetch")
122
+ else
123
+ FileUtils.mkdir_p(File.dirname(remote_root_path))
124
+ Util.system("git clone #{remote_repository} #{remote_root_path}")
125
+ end
126
+
127
+ Util.system("git -C #{remote_root_path} checkout origin/#{remote_branch}")
128
+ File.open(update_timestamp_path, 'w') { |f| f.write(Time.now.to_i.to_s + "\n") }
129
+ end
130
+
131
+ if verbose
132
+ puts
133
+ puts " Repository....: \e[33m#{wd.remote_repository}\e[0m"
134
+ puts " Branch........: \e[33m#{wd.remote_branch}\e[0m"
135
+ puts " Path..........: \e[33m#{wd.remote_path}\e[0m"
136
+ puts
137
+ end
138
+
139
+ true
140
+ end
141
+
142
+ # Return the time this manifest was last updated
143
+ #
144
+ # @return [Time, nil]
145
+ def last_updated_at
146
+ if File.file?(update_timestamp_path)
147
+ timestamp = File.read(update_timestamp_path)
148
+ Time.at(timestamp.strip.to_i)
149
+ end
150
+ end
151
+
152
+ # Return the path to the file where the last updated timestamp
153
+ # is stored
154
+ #
155
+ # @return [String]
156
+ def update_timestamp_path
157
+ File.join(remote_root_path + '.uptime-timestamp')
158
+ end
159
+
160
+ # Can this working directory be updated?
161
+ #
162
+ # @return [Boolean]
163
+ def can_update?
164
+ source_type == 'remote'
165
+ end
166
+
167
+ # Load all stages that are available in this working directory
168
+ #
169
+ # @return [Hash<Symbol, Hippo::Stage>]
170
+ def stages
171
+ objects = Util.load_objects_from_path(File.join(@root, '*', 'config.{yml,yaml}'))
172
+ objects.each_with_object({}) do |(path, objects), hash|
173
+ objects.each do |obj|
174
+ stage = Stage.new(self, File.dirname(path), obj)
175
+ hash[stage.name] = stage
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end