kubernetes-deploy 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,42 @@
1
+ require 'active_support/core_ext/object/blank'
2
+ require 'active_support/core_ext/hash/slice'
3
+ require 'active_support/core_ext/numeric/time'
4
+
5
+ require 'logger'
6
+ require 'kubernetes-deploy/runner'
7
+
8
+ module KubernetesDeploy
9
+ class FatalDeploymentError < StandardError; end
10
+
11
+ def self.logger=(value)
12
+ @logger = value
13
+ end
14
+
15
+ def self.logger
16
+ @logger ||= begin
17
+ l = Logger.new($stderr)
18
+ l.level = level_from_env
19
+ l.formatter = proc do |severity, _datetime, _progname, msg|
20
+ case severity
21
+ when "FATAL", "ERROR" then "\033[0;31m[#{severity}]\t#{msg}\x1b[0m\n" # red
22
+ when "WARN" then "\033[0;33m[#{severity}]\t#{msg}\x1b[0m\n" # yellow
23
+ when "INFO" then "\033[0;36m#{msg}\x1b[0m\n" # blue
24
+ else "[#{severity}]\t#{msg}\n"
25
+ end
26
+ end
27
+ l
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def self.level_from_env
34
+ return Logger::DEBUG if ENV["DEBUG"]
35
+
36
+ if ENV["LEVEL"]
37
+ Logger.const_get(ENV["LEVEL"].upcase)
38
+ else
39
+ Logger::INFO
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,102 @@
1
+ require 'json'
2
+ require 'open3'
3
+ require 'shellwords'
4
+
5
+ module KubernetesDeploy
6
+ class KubernetesResource
7
+
8
+ attr_reader :name, :namespace, :file
9
+ attr_writer :type, :deploy_started
10
+
11
+ TIMEOUT = 5.minutes
12
+
13
+ def self.for_type(type, name, namespace, file)
14
+ case type
15
+ when 'configmap' then ConfigMap.new(name, namespace, file)
16
+ when 'deployment' then Deployment.new(name, namespace, file)
17
+ when 'pod' then Pod.new(name, namespace, file)
18
+ when 'ingress' then Ingress.new(name, namespace, file)
19
+ when 'persistentvolumeclaim' then PersistentVolumeClaim.new(name, namespace, file)
20
+ when 'service' then Service.new(name, namespace, file)
21
+ else self.new(name, namespace, file).tap { |r| r.type = type }
22
+ end
23
+ end
24
+
25
+ def initialize(name, namespace, file)
26
+ # subclasses must also set these
27
+ @name, @namespace, @file = name, namespace, file
28
+ end
29
+
30
+ def id
31
+ "#{type}/#{name}"
32
+ end
33
+
34
+ def sync
35
+ log_status
36
+ end
37
+
38
+ def deploy_failed?
39
+ false
40
+ end
41
+
42
+ def deploy_succeeded?
43
+ if @deploy_started && !@success_assumption_warning_shown
44
+ KubernetesDeploy.logger.warn("Don't know how to monitor resources of type #{type}. Assuming #{id} deployed successfully.")
45
+ @success_assumption_warning_shown = true
46
+ end
47
+ true
48
+ end
49
+
50
+ def exists?
51
+ nil
52
+ end
53
+
54
+ def status
55
+ @status ||= "Unknown"
56
+ deploy_timed_out? ? "Timed out with status #{@status}" : @status
57
+ end
58
+
59
+ def type
60
+ @type || self.class.name.split('::').last
61
+ end
62
+
63
+ def deploy_finished?
64
+ deploy_failed? || deploy_succeeded? || deploy_timed_out?
65
+ end
66
+
67
+ def deploy_timed_out?
68
+ return false unless @deploy_started
69
+ !deploy_succeeded? && !deploy_failed? && (Time.now.utc - @deploy_started > self.class::TIMEOUT)
70
+ end
71
+
72
+ def status_data
73
+ {
74
+ group: group_name,
75
+ name: name,
76
+ status_string: status,
77
+ exists: exists?,
78
+ succeeded: deploy_succeeded?,
79
+ failed: deploy_failed?,
80
+ timed_out: deploy_timed_out?
81
+ }
82
+ end
83
+
84
+ def group_name
85
+ type + "s"
86
+ end
87
+
88
+ def run_kubectl(*args)
89
+ raise FatalDeploymentError, "Namespace missing for namespaced command" if namespace.blank?
90
+ args = args.unshift("kubectl").push("--namespace=#{namespace}")
91
+ KubernetesDeploy.logger.debug Shellwords.join(args)
92
+ out, err, st = Open3.capture3(*args)
93
+ KubernetesDeploy.logger.debug(out.shellescape)
94
+ KubernetesDeploy.logger.debug("[ERROR] #{err.shellescape}") unless st.success?
95
+ [out.chomp, st]
96
+ end
97
+
98
+ def log_status
99
+ STDOUT.puts "[KUBESTATUS] #{JSON.dump(status_data)}"
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,28 @@
1
+ module KubernetesDeploy
2
+ class ConfigMap < KubernetesResource
3
+ TIMEOUT = 30.seconds
4
+
5
+ def initialize(name, namespace, file)
6
+ @name, @namespace, @file = name, namespace, file
7
+ end
8
+
9
+ def sync
10
+ _, st = run_kubectl("get", type, @name)
11
+ @status = st.success? ? "Available" : "Unknown"
12
+ @found = st.success?
13
+ log_status
14
+ end
15
+
16
+ def deploy_succeeded?
17
+ exists?
18
+ end
19
+
20
+ def deploy_failed?
21
+ false
22
+ end
23
+
24
+ def exists?
25
+ @found
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,62 @@
1
+ module KubernetesDeploy
2
+ class Deployment < KubernetesResource
3
+ TIMEOUT = 15.minutes
4
+
5
+ def initialize(name, namespace, file)
6
+ @name, @namespace, @file = name, namespace, file
7
+ end
8
+
9
+ def sync
10
+ json_data, st = run_kubectl("get", type, @name, "--output=json")
11
+ @found = st.success?
12
+ @rollout_data = {}
13
+ @status = nil
14
+ @pods = []
15
+
16
+ if @found
17
+ @rollout_data = JSON.parse(json_data)["status"].slice("updatedReplicas", "replicas", "availableReplicas", "unavailableReplicas")
18
+ @status, _ = run_kubectl("rollout", "status", type, @name, "--watch=false") if @deploy_started
19
+
20
+ pod_list, st = run_kubectl("get", "pods", "-a", "-l", "name=#{name}", "--output=json")
21
+ if st.success?
22
+ pods_json = JSON.parse(pod_list)["items"]
23
+ pods_json.each do |pod_json|
24
+ pod_name = pod_json["metadata"]["name"]
25
+ pod = Pod.new(pod_name, namespace, nil, parent: "#{@name.capitalize} deployment")
26
+ pod.deploy_started = @deploy_started
27
+ pod.interpret_json_data(pod_json)
28
+ pod.log_status
29
+ @pods << pod
30
+ end
31
+ end
32
+ end
33
+
34
+ log_status
35
+ end
36
+
37
+ def deploy_succeeded?
38
+ return false unless @rollout_data.key?("availableReplicas")
39
+ # TODO: this should look at the current replica set's pods too
40
+ @rollout_data["availableReplicas"].to_i == @pods.length &&
41
+ @rollout_data.values.uniq.length == 1 # num desired, current, up-to-date and available are equal
42
+ end
43
+
44
+ def deploy_failed?
45
+ # TODO: this should look at the current replica set's pods only or it'll never be true for rolling updates
46
+ @pods.present? && @pods.all?(&:deploy_failed?)
47
+ end
48
+
49
+ def deploy_timed_out?
50
+ # TODO: this should look at the current replica set's pods only or it'll never be true for rolling updates
51
+ super || @pods.present? && @pods.all?(&:deploy_timed_out?)
52
+ end
53
+
54
+ def exists?
55
+ @found
56
+ end
57
+
58
+ def status_data
59
+ super.merge(replicas: @rollout_data, num_pods: @pods.length)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,32 @@
1
+ module KubernetesDeploy
2
+ class Ingress < KubernetesResource
3
+ TIMEOUT = 30.seconds
4
+
5
+ def initialize(name, namespace, file)
6
+ @name, @namespace, @file = name, namespace, file
7
+ end
8
+
9
+ def sync
10
+ _, st = run_kubectl("get", type, @name)
11
+ @status = st.success? ? "Created" : "Unknown"
12
+ @found = st.success?
13
+ log_status
14
+ end
15
+
16
+ def deploy_succeeded?
17
+ exists?
18
+ end
19
+
20
+ def deploy_failed?
21
+ false
22
+ end
23
+
24
+ def exists?
25
+ @found
26
+ end
27
+
28
+ def group_name
29
+ "Ingresses"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ module KubernetesDeploy
2
+ class PersistentVolumeClaim < KubernetesResource
3
+ TIMEOUT = 5.minutes
4
+
5
+ def initialize(name, namespace, file)
6
+ @name, @namespace, @file = name, namespace, file
7
+ end
8
+
9
+ def sync
10
+ @status, st = run_kubectl("get", type, @name, "--output=jsonpath={.status.phase}")
11
+ @found = st.success?
12
+ log_status
13
+ end
14
+
15
+ def deploy_succeeded?
16
+ @status == "Bound"
17
+ end
18
+
19
+ def deploy_failed?
20
+ @status == "Lost"
21
+ end
22
+
23
+ def exists?
24
+ @found
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,84 @@
1
+ module KubernetesDeploy
2
+ class Pod < KubernetesResource
3
+ TIMEOUT = 15.minutes
4
+ SUSPICIOUS_CONTAINER_STATES = %w(ImagePullBackOff RunContainerError).freeze
5
+
6
+ def initialize(name, namespace, file, parent: nil)
7
+ @name, @namespace, @file, @parent = name, namespace, file, parent
8
+ @bare = !@parent
9
+ end
10
+
11
+ def sync
12
+ out, st = run_kubectl("get", type, @name, "-a", "--output=json")
13
+ if @found = st.success?
14
+ pod_data = JSON.parse(out)
15
+ interpret_json_data(pod_data)
16
+ else # reset
17
+ @status = @phase = nil
18
+ @ready = false
19
+ @containers = []
20
+ end
21
+ display_logs if @bare && deploy_finished?
22
+ log_status
23
+ end
24
+
25
+ def interpret_json_data(pod_data)
26
+ @phase = (pod_data["metadata"]["deletionTimestamp"] ? "Terminating" : pod_data["status"]["phase"])
27
+ @containers = pod_data["spec"]["containers"].map { |c| c["name"] }
28
+
29
+ if @deploy_started && pod_data["status"]["containerStatuses"]
30
+ pod_data["status"]["containerStatuses"].each do |status|
31
+ waiting_state = status["state"]["waiting"] if status["state"]
32
+ reason = waiting_state["reason"] if waiting_state
33
+ next unless SUSPICIOUS_CONTAINER_STATES.include?(reason)
34
+ KubernetesDeploy.logger.warn("#{id} has container in state #{reason} (#{waiting_state["message"]})")
35
+ end
36
+ end
37
+
38
+ if @phase == "Failed"
39
+ @status = "#{@phase} (Reason: #{pod_data["status"]["reason"]})"
40
+ elsif @phase == "Terminating"
41
+ @status = @phase
42
+ else
43
+ ready_condition = pod_data["status"]["conditions"].find { |condition| condition["type"] == "Ready" }
44
+ @ready = ready_condition.present? && (ready_condition["status"] == "True")
45
+ @status = "#{@phase} (Ready: #{@ready})"
46
+ end
47
+ end
48
+
49
+ def deploy_succeeded?
50
+ if @bare
51
+ @phase == "Succeeded"
52
+ else
53
+ @phase == "Running" && @ready
54
+ end
55
+ end
56
+
57
+ def deploy_failed?
58
+ @phase == "Failed"
59
+ end
60
+
61
+ def exists?
62
+ @bare ? @found : true
63
+ end
64
+
65
+ def group_name
66
+ @bare ? "Bare pods" : @parent
67
+ end
68
+
69
+ private
70
+
71
+ def display_logs
72
+ return {} unless exists? && @containers.present? && !@already_displayed
73
+
74
+ @containers.each do |container_name|
75
+ out, st = run_kubectl("logs", @name, "--timestamps=true", "--since-time=#{@deploy_started.to_datetime.rfc3339}")
76
+ next unless st.success? && out.present?
77
+
78
+ KubernetesDeploy.logger.info "Logs from #{id} container #{container_name}:"
79
+ STDOUT.puts "#{out}"
80
+ @already_displayed = true
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,34 @@
1
+ module KubernetesDeploy
2
+ class Service < KubernetesResource
3
+ TIMEOUT = 15.minutes
4
+
5
+ def initialize(name, namespace, file)
6
+ @name, @namespace, @file = name, namespace, file
7
+ end
8
+
9
+ def sync
10
+ _, st = run_kubectl("get", type, @name)
11
+ @found = st.success?
12
+ if @found
13
+ endpoints, st = run_kubectl("get", "endpoints", @name, "--output=jsonpath={.subsets[*].addresses[*].ip}")
14
+ @num_endpoints = (st.success? ? endpoints.split.length : 0)
15
+ else
16
+ @num_endpoints = 0
17
+ end
18
+ @status = "#{@num_endpoints} endpoints"
19
+ log_status
20
+ end
21
+
22
+ def deploy_succeeded?
23
+ @num_endpoints > 0
24
+ end
25
+
26
+ def deploy_failed?
27
+ false
28
+ end
29
+
30
+ def exists?
31
+ @found
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,285 @@
1
+ require 'open3'
2
+ require 'securerandom'
3
+ require 'erb'
4
+ require 'yaml'
5
+ require 'shellwords'
6
+ require 'tempfile'
7
+
8
+ require 'kubernetes-deploy/kubernetes_resource'
9
+ %w(
10
+ config_map
11
+ deployment
12
+ ingress
13
+ persistent_volume_claim
14
+ pod
15
+ service
16
+ ).each do |subresource|
17
+ require "kubernetes-deploy/kubernetes_resource/#{subresource}"
18
+ end
19
+
20
+ module KubernetesDeploy
21
+ class Runner
22
+ PREDEPLOY_SEQUENCE = %w(
23
+ ConfigMap
24
+ PersistentVolumeClaim
25
+ Pod
26
+ )
27
+
28
+ # Things removed from default prune whitelist:
29
+ # core/v1/Namespace -- not namespaced
30
+ # core/v1/PersistentVolume -- not namespaced
31
+ # core/v1/Endpoints -- managed by services
32
+ # core/v1/PersistentVolumeClaim -- would delete data
33
+ # core/v1/ReplicationController -- superseded by deployments/replicasets
34
+ # extensions/v1beta1/ReplicaSet -- managed by deployments
35
+ # core/v1/Secret -- should not committed / managed by shipit
36
+ PRUNE_WHITELIST = %w(
37
+ core/v1/ConfigMap
38
+ core/v1/Pod
39
+ core/v1/Service
40
+ batch/v1/Job
41
+ extensions/v1beta1/DaemonSet
42
+ extensions/v1beta1/Deployment
43
+ extensions/v1beta1/HorizontalPodAutoscaler
44
+ extensions/v1beta1/Ingress
45
+ apps/v1beta1/StatefulSet
46
+ ).freeze
47
+
48
+ def self.with_friendly_errors
49
+ yield
50
+ rescue FatalDeploymentError => error
51
+ KubernetesDeploy.logger.fatal <<-MSG
52
+ #{error.class}: #{error.message}
53
+ #{error.backtrace && error.backtrace.join("\n ")}
54
+ MSG
55
+ exit 1
56
+ end
57
+
58
+ def initialize(namespace:, environment:, current_sha:, context:, wait_for_completion:, template_folder: nil)
59
+ @namespace = namespace
60
+ @context = context
61
+ @current_sha = current_sha
62
+ @template_path = File.expand_path('./' + (template_folder || "config/deploy/#{environment}"))
63
+ # Max length of podname is only 63chars so try to save some room by truncating sha to 8 chars
64
+ @id = current_sha[0...8] + "-#{SecureRandom.hex(4)}" if current_sha
65
+ @wait_for_completion = wait_for_completion
66
+ end
67
+
68
+ def wait_for_completion?
69
+ @wait_for_completion
70
+ end
71
+
72
+ def run
73
+ @current_phase = 0
74
+ phase_heading("Validating configuration")
75
+ validate_configuration
76
+
77
+ phase_heading("Configuring kubectl")
78
+ set_kubectl_context
79
+ validate_namespace
80
+
81
+ phase_heading("Parsing deploy content")
82
+ resources = discover_resources
83
+
84
+ phase_heading("Checking initial resource statuses")
85
+ resources.each(&:sync)
86
+
87
+ phase_heading("Predeploying priority resources")
88
+ predeploy_priority_resources(resources)
89
+
90
+ phase_heading("Deploying all resources")
91
+ deploy_resources(resources, prune: true)
92
+
93
+ return unless wait_for_completion?
94
+ wait_for_completion(resources)
95
+ report_final_status(resources)
96
+ end
97
+
98
+ def template_variables
99
+ {
100
+ 'current_sha' => @current_sha,
101
+ 'deployment_id' => @id,
102
+ }
103
+ end
104
+
105
+ private
106
+
107
+ def predeploy_priority_resources(resource_list)
108
+ PREDEPLOY_SEQUENCE.each do |resource_type|
109
+ matching_resources = resource_list.select { |r| r.type == resource_type }
110
+ next if matching_resources.empty?
111
+ deploy_resources(matching_resources)
112
+ wait_for_completion(matching_resources)
113
+ fail_count = matching_resources.count { |r| r.deploy_failed? || r.deploy_timed_out? }
114
+ if fail_count > 0
115
+ raise FatalDeploymentError, "#{fail_count} priority resources failed to deploy"
116
+ end
117
+ end
118
+ end
119
+
120
+ def discover_resources
121
+ resources = []
122
+ Dir.foreach(@template_path) do |filename|
123
+ next unless filename.end_with?(".yml.erb", ".yml")
124
+
125
+ split_templates(filename) do |tempfile|
126
+ resource_id = discover_resource_via_dry_run(tempfile)
127
+ type, name = resource_id.split("/", 2) # e.g. "pod/web-198612918-dzvfb"
128
+ resources << KubernetesResource.for_type(type, name, @namespace, tempfile)
129
+ KubernetesDeploy.logger.info "Discovered template for #{resource_id}"
130
+ end
131
+ end
132
+ resources
133
+ end
134
+
135
+ def discover_resource_via_dry_run(tempfile)
136
+ resource_id, err, st = run_kubectl("apply", "-f", tempfile.path, "--dry-run", "--output=name")
137
+ raise FatalDeploymentError, "Dry run failed for template #{File.basename(tempfile.path)}." unless st.success?
138
+ resource_id
139
+ end
140
+
141
+ def split_templates(filename)
142
+ file_content = File.read(File.join(@template_path, filename))
143
+ rendered_content = render_template(filename, file_content)
144
+ YAML.load_stream(rendered_content) do |doc|
145
+ f = Tempfile.new(filename)
146
+ f.write(YAML.dump(doc))
147
+ f.close
148
+ yield f
149
+ end
150
+ rescue Psych::SyntaxError => e
151
+ KubernetesDeploy.logger.error(rendered_content)
152
+ raise FatalDeploymentError, "Template #{filename} cannot be parsed: #{e.message}"
153
+ end
154
+
155
+ def report_final_status(resources)
156
+ if resources.all?(&:deploy_succeeded?)
157
+ log_green("Deploy succeeded!")
158
+ else
159
+ fail_list = resources.select { |r| r.deploy_failed? || r.deploy_timed_out? }.map(&:id)
160
+ KubernetesDeploy.logger.error("The following resources failed to deploy: #{fail_list.join(", ")}")
161
+ raise FatalDeploymentError, "#{fail_list.length} resources failed to deploy"
162
+ end
163
+ end
164
+
165
+ def wait_for_completion(watched_resources)
166
+ delay_sync_until = Time.now.utc
167
+ while watched_resources.present?
168
+ if Time.now.utc < delay_sync_until
169
+ sleep (delay_sync_until - Time.now.utc)
170
+ end
171
+ delay_sync_until = Time.now.utc + 3 # don't pummel the API if the sync is fast
172
+ watched_resources.each(&:sync)
173
+ newly_finished_resources, watched_resources = watched_resources.partition(&:deploy_finished?)
174
+ newly_finished_resources.each do |resource|
175
+ next unless resource.deploy_failed? || resource.deploy_timed_out?
176
+ KubernetesDeploy.logger.error("#{resource.id} failed to deploy with status '#{resource.status}'.")
177
+ KubernetesDeploy.logger.error("This script will continue to poll until the status of all resources deployed in this phase is resolved, but the deploy is now doomed and you may wish abort it.")
178
+ KubernetesDeploy.logger.error(resource.status_data)
179
+ end
180
+ end
181
+ end
182
+
183
+ def render_template(filename, raw_template)
184
+ return raw_template unless File.extname(filename) == ".erb"
185
+
186
+ erb_template = ERB.new(raw_template)
187
+ erb_binding = binding
188
+ template_variables.each do |var_name, value|
189
+ erb_binding.local_variable_set(var_name, value)
190
+ end
191
+ erb_template.result(erb_binding)
192
+ end
193
+
194
+ def validate_configuration
195
+ errors = []
196
+ if ENV["KUBECONFIG"].blank? || !File.file?(ENV["KUBECONFIG"])
197
+ errors << "Kube config not found at #{ENV["KUBECONFIG"]}"
198
+ end
199
+
200
+ if @current_sha.blank?
201
+ errors << "Current SHA must be specified"
202
+ end
203
+
204
+ if !File.directory?(@template_path)
205
+ errors << "Template path #{@template_path} doesn't exist"
206
+ elsif Dir.entries(@template_path).none? { |file| file =~ /\.yml(\.erb)?$/ }
207
+ errors << "#{@template_path} doesn't contain valid templates (postfix .yml or .yml.erb)"
208
+ end
209
+
210
+ if @namespace.blank?
211
+ errors << "Namespace must be specified"
212
+ end
213
+
214
+ if @context.blank?
215
+ errors << "Context must be specified"
216
+ end
217
+
218
+ raise FatalDeploymentError, "Configuration invalid: #{errors.join(", ")}" unless errors.empty?
219
+ KubernetesDeploy.logger.info("All required parameters and files are present")
220
+ end
221
+
222
+ def deploy_resources(resources, prune: false)
223
+ command = ["apply", "--namespace=#{@namespace}"]
224
+ KubernetesDeploy.logger.info("Deploying resources:")
225
+
226
+ resources.each do |r|
227
+ KubernetesDeploy.logger.info("- #{r.id}")
228
+ command.push("-f", r.file.path)
229
+ r.deploy_started = Time.now.utc
230
+ end
231
+
232
+ if prune
233
+ command.push("--prune", "--all")
234
+ PRUNE_WHITELIST.each { |type| command.push("--prune-whitelist=#{type}") }
235
+ end
236
+
237
+ run_kubectl(*command)
238
+ end
239
+
240
+ def set_kubectl_context
241
+ out, err, st = run_kubectl("config", "get-contexts", "-o", "name", namespaced: false)
242
+ available_contexts = out.split("\n")
243
+ if !st.success?
244
+ raise FatalDeploymentError, err
245
+ elsif !available_contexts.include?(@context)
246
+ raise FatalDeploymentError, "Context #{@context} is not available. Valid contexts: #{available_contexts}"
247
+ end
248
+
249
+ _, err, st = run_kubectl("config", "use-context", @context, namespaced: false)
250
+ raise FatalDeploymentError, "Kubectl config is not valid: #{err}" unless st.success?
251
+ KubernetesDeploy.logger.info("Kubectl configured to use context #{@context}")
252
+ end
253
+
254
+ def validate_namespace
255
+ _, _, st = run_kubectl("get", "namespace", @namespace, namespaced: false)
256
+ raise FatalDeploymentError, "Failed to validate namespace #{@namespace}" unless st.success?
257
+ KubernetesDeploy.logger.info("Namespace #{@namespace} validated")
258
+ end
259
+
260
+ def run_kubectl(*args, namespaced: true)
261
+ args = args.unshift("kubectl")
262
+ if namespaced
263
+ raise FatalDeploymentError, "Namespace missing for namespaced command" unless @namespace
264
+ args.push("--namespace=#{@namespace}")
265
+ end
266
+ KubernetesDeploy.logger.debug Shellwords.join(args)
267
+ out, err, st = Open3.capture3(*args)
268
+ KubernetesDeploy.logger.debug(out.shellescape)
269
+ KubernetesDeploy.logger.warn(err) unless st.success?
270
+ [out.chomp, err.chomp, st]
271
+ end
272
+
273
+ def phase_heading(phase_name)
274
+ @current_phase += 1
275
+ heading = "Phase #{@current_phase}: #{phase_name}"
276
+ padding = (100.0 - heading.length)/2
277
+ KubernetesDeploy.logger.info("")
278
+ KubernetesDeploy.logger.info("#{'-' * padding.floor}#{heading}#{'-' * padding.ceil}")
279
+ end
280
+
281
+ def log_green(msg)
282
+ STDOUT.puts "\033[0;32m#{msg}\x1b[0m\n" # green
283
+ end
284
+ end
285
+ end