hippo-cli 1.1.1 → 1.2.3

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