gclouder 0.1.1
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.
- checksums.yaml +7 -0
- data/README.md +104 -0
- data/bin/gclouder +7 -0
- data/lib/gclouder/config/arguments.rb +35 -0
- data/lib/gclouder/config/cli_args.rb +77 -0
- data/lib/gclouder/config/cluster.rb +66 -0
- data/lib/gclouder/config/defaults.rb +35 -0
- data/lib/gclouder/config/files/project.rb +24 -0
- data/lib/gclouder/config/project.rb +34 -0
- data/lib/gclouder/config/resource_representations.rb +31 -0
- data/lib/gclouder/config_loader.rb +25 -0
- data/lib/gclouder/config_section.rb +26 -0
- data/lib/gclouder/dependencies.rb +11 -0
- data/lib/gclouder/gcloud.rb +39 -0
- data/lib/gclouder/gsutil.rb +25 -0
- data/lib/gclouder/header.rb +9 -0
- data/lib/gclouder/helpers.rb +77 -0
- data/lib/gclouder/logging.rb +181 -0
- data/lib/gclouder/mappings/argument.rb +31 -0
- data/lib/gclouder/mappings/file.rb +31 -0
- data/lib/gclouder/mappings/property.rb +31 -0
- data/lib/gclouder/mappings/resource_representation.rb +31 -0
- data/lib/gclouder/monkey_patches/array.rb +19 -0
- data/lib/gclouder/monkey_patches/boolean.rb +12 -0
- data/lib/gclouder/monkey_patches/hash.rb +44 -0
- data/lib/gclouder/monkey_patches/ipaddr.rb +10 -0
- data/lib/gclouder/monkey_patches/string.rb +30 -0
- data/lib/gclouder/resource.rb +63 -0
- data/lib/gclouder/resource_cleaner.rb +58 -0
- data/lib/gclouder/resources/compute/addresses.rb +108 -0
- data/lib/gclouder/resources/compute/bgp-vpns.rb +220 -0
- data/lib/gclouder/resources/compute/disks.rb +99 -0
- data/lib/gclouder/resources/compute/firewall_rules.rb +82 -0
- data/lib/gclouder/resources/compute/instances.rb +147 -0
- data/lib/gclouder/resources/compute/networks/subnets.rb +104 -0
- data/lib/gclouder/resources/compute/networks.rb +110 -0
- data/lib/gclouder/resources/compute/project_info/ssh_keys.rb +171 -0
- data/lib/gclouder/resources/compute/routers.rb +83 -0
- data/lib/gclouder/resources/compute/vpns.rb +199 -0
- data/lib/gclouder/resources/container/clusters.rb +257 -0
- data/lib/gclouder/resources/container/node_pools.rb +193 -0
- data/lib/gclouder/resources/dns.rb +390 -0
- data/lib/gclouder/resources/logging/sinks.rb +98 -0
- data/lib/gclouder/resources/project/iam_policy_binding.rb +293 -0
- data/lib/gclouder/resources/project.rb +85 -0
- data/lib/gclouder/resources/project_id.rb +71 -0
- data/lib/gclouder/resources/pubsub/subscriptions.rb +100 -0
- data/lib/gclouder/resources/pubsub/topics.rb +95 -0
- data/lib/gclouder/resources/storagebuckets.rb +103 -0
- data/lib/gclouder/resources/validate/global.rb +27 -0
- data/lib/gclouder/resources/validate/local.rb +68 -0
- data/lib/gclouder/resources/validate/region.rb +28 -0
- data/lib/gclouder/resources/validate/remote.rb +78 -0
- data/lib/gclouder/resources.rb +148 -0
- data/lib/gclouder/shell.rb +71 -0
- data/lib/gclouder/version.rb +5 -0
- data/lib/gclouder.rb +278 -0
- metadata +102 -0
@@ -0,0 +1,103 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
module GClouder
|
4
|
+
module Resources
|
5
|
+
module StorageBuckets
|
6
|
+
include GClouder::Logging
|
7
|
+
include GClouder::Shell
|
8
|
+
include GClouder::Resource::Cleaner
|
9
|
+
|
10
|
+
def self.header(stage = :ensure)
|
11
|
+
info "[#{stage}] storage / buckets", title: true, indent: 1
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.validate
|
15
|
+
return if Local.list.empty?
|
16
|
+
header :validate
|
17
|
+
Local.validate
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.ensure
|
21
|
+
return if Local.list.empty?
|
22
|
+
header
|
23
|
+
|
24
|
+
Local.list.each do |region, region_config|
|
25
|
+
info region, heading: true, indent: 2
|
26
|
+
region_config.each do |bucket|
|
27
|
+
info
|
28
|
+
StorageBucket.ensure(region, bucket)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
module Local
|
34
|
+
def self.list
|
35
|
+
instances
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.validate
|
39
|
+
# Validation knowledge included here because we don't have arguments parser for gsutil.
|
40
|
+
# We also don't support every key that gsutil does. See StorageBucket.ensure() below.
|
41
|
+
permitted_and_required_keys = {
|
42
|
+
"default_access"=>{"type"=>"String", "required"=>true}
|
43
|
+
}
|
44
|
+
|
45
|
+
Resources::Validate::Region.instances(
|
46
|
+
instances,
|
47
|
+
permitted_keys: permitted_and_required_keys
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.instances
|
52
|
+
Resources::Region.instances(path: ["storage", "buckets"])
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
module Remote
|
57
|
+
include GClouder::GSUtil
|
58
|
+
|
59
|
+
# FIXME: make more robust(!)
|
60
|
+
def self.list
|
61
|
+
gsutil("ls", "-L").to_s.split("gs://").delete_if(&:empty?).each_with_object({}) do |data, collection|
|
62
|
+
normalized = data.split("\n").map! { |b| b.delete("\t") }
|
63
|
+
bucket_name = normalized[0].delete("/ :")
|
64
|
+
region = normalized.select { |e| e.match("^Location constraint:") }.last.split(":").last.downcase
|
65
|
+
collection[region] ||= []
|
66
|
+
collection[region] << { "name" => bucket_name }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
module StorageBucket
|
72
|
+
include GClouder::GSUtil
|
73
|
+
include GClouder::Config::CLIArgs
|
74
|
+
|
75
|
+
def self.setDefaultAccess(bucket_name, default_access)
|
76
|
+
info "# gsutil defacl ch -u #{default_access} gs://#{bucket_name}" if cli_args[:debug]
|
77
|
+
|
78
|
+
return if cli_args[:dry_run]
|
79
|
+
|
80
|
+
# Just use shell, as -p flag is not valid for 'defacl ch'.
|
81
|
+
shell("gsutil defacl ch -u #{default_access} gs://#{bucket_name}")
|
82
|
+
end
|
83
|
+
|
84
|
+
def self.check_exists?(region, bucket_name)
|
85
|
+
gsutil_exec("ls", "gs://#{bucket_name} > /dev/null 2>&1 && echo 0 || echo 1").to_i == 0
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.ensure(region, bucket)
|
89
|
+
if check_exists?(region, bucket["name"])
|
90
|
+
good bucket["name"]
|
91
|
+
return
|
92
|
+
end
|
93
|
+
|
94
|
+
add "#{bucket["name"]} [#{bucket["default_access"]}]"
|
95
|
+
gsutil "mb", "-l #{region} gs://#{bucket["name"]}"
|
96
|
+
|
97
|
+
|
98
|
+
setDefaultAccess bucket["name"], bucket["default_access"] if bucket.key?("default_access")
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
module GClouder
|
4
|
+
module Resources
|
5
|
+
module Validate
|
6
|
+
module Global
|
7
|
+
include GClouder::Logging
|
8
|
+
include GClouder::Config::Project
|
9
|
+
include GClouder::Config::CLIArgs
|
10
|
+
include Local
|
11
|
+
|
12
|
+
def self.instances(data, required_keys: {}, permitted_keys: {}, ignore_keys: [])
|
13
|
+
return unless data.key?("global")
|
14
|
+
|
15
|
+
data["global"].each do |instance|
|
16
|
+
info instance["name"], heading: true, indent: 3
|
17
|
+
|
18
|
+
next if !has_unknown_keys?(instance, permitted_keys, ignore_keys) &&
|
19
|
+
has_required_keys?(instance, required_keys, ignore_keys, indent: 3)
|
20
|
+
|
21
|
+
fatal "\nerror: validation failure"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
module GClouder
|
4
|
+
module Resources
|
5
|
+
module Validate
|
6
|
+
module Local
|
7
|
+
include GClouder::Logging
|
8
|
+
|
9
|
+
def self.included(klass)
|
10
|
+
klass.extend Local
|
11
|
+
end
|
12
|
+
|
13
|
+
# FIXME: this should probably recurse
|
14
|
+
def has_required_keys?(instance, required_keys, ignore_keys, indent: 3)
|
15
|
+
success = true
|
16
|
+
|
17
|
+
required_keys["name"] = {
|
18
|
+
"type" => "String",
|
19
|
+
"required" => true
|
20
|
+
}
|
21
|
+
|
22
|
+
required_keys.each do |key, data|
|
23
|
+
next if ignore_keys.include?(key)
|
24
|
+
|
25
|
+
if !instance.key?(key)
|
26
|
+
bad "#{key} is missing", indent: indent
|
27
|
+
success = false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
success
|
32
|
+
end
|
33
|
+
|
34
|
+
def has_unknown_keys?(instance, permitted_keys, ignore_keys, indent: 0)
|
35
|
+
success = false
|
36
|
+
|
37
|
+
# a name is required for every resources
|
38
|
+
permitted_keys["name"] = {
|
39
|
+
"type" => "String",
|
40
|
+
"required" => true
|
41
|
+
}
|
42
|
+
|
43
|
+
instance.each do |key, value|
|
44
|
+
next if ignore_keys.include?(key)
|
45
|
+
|
46
|
+
if !permitted_keys.key?(key)
|
47
|
+
bad "#{key} is an invalid key", indent: 4 + indent
|
48
|
+
success = true
|
49
|
+
next
|
50
|
+
end
|
51
|
+
|
52
|
+
required_type = Object.const_get(permitted_keys[key]["type"])
|
53
|
+
|
54
|
+
if !value.is_a?(required_type)
|
55
|
+
bad "#{key} invalid type: #{value.class} (should be: #{required_type})", indent: 4 + indent
|
56
|
+
success = true
|
57
|
+
next
|
58
|
+
end
|
59
|
+
|
60
|
+
good "#{key} is a #{required_type} (#{value})", indent: 4 + indent
|
61
|
+
end
|
62
|
+
|
63
|
+
success
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
module GClouder
|
4
|
+
module Resources
|
5
|
+
module Validate
|
6
|
+
module Region
|
7
|
+
include GClouder::Logging
|
8
|
+
include GClouder::Config::Project
|
9
|
+
include GClouder::Config::CLIArgs
|
10
|
+
extend Local
|
11
|
+
|
12
|
+
def self.instances(data, required_keys: {}, permitted_keys: {}, ignore_keys: [], skip_region: false, indent: 0)
|
13
|
+
data.each do |region, instances|
|
14
|
+
info region, indent: 2 + indent, heading: true unless skip_region
|
15
|
+
instances.each do |instance|
|
16
|
+
info instance["name"], indent: 3 + indent, heading: true
|
17
|
+
|
18
|
+
next if !has_unknown_keys?(instance, permitted_keys, ignore_keys, indent: indent) &&
|
19
|
+
has_required_keys?(instance, required_keys, ignore_keys, indent: 4 + indent)
|
20
|
+
|
21
|
+
fatal "\nerror: validation failure"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
module GClouder
|
4
|
+
module Resources
|
5
|
+
module Validate
|
6
|
+
module Remote
|
7
|
+
include GClouder::Logging
|
8
|
+
include GClouder::Config::Project
|
9
|
+
include GClouder::Config::CLIArgs
|
10
|
+
|
11
|
+
def self.instances(local, remote, skip_keys: [])
|
12
|
+
remote.each do |region, resources|
|
13
|
+
info region, heading: true, indent: 2
|
14
|
+
resources.each do |resource|
|
15
|
+
#FIXME: This won't work with duplicate names
|
16
|
+
local_config = local.fetch(region, []).select {|s| s["name"] == resource["name"] }.first
|
17
|
+
|
18
|
+
failure = false
|
19
|
+
|
20
|
+
next unless local_config
|
21
|
+
|
22
|
+
info resource["name"], indent: 3, heading: true
|
23
|
+
|
24
|
+
local_config.each do |key, value|
|
25
|
+
skipped = false
|
26
|
+
skip_message = nil
|
27
|
+
|
28
|
+
# FIXME: we should recurse down into the data structure and check the values..
|
29
|
+
if value.is_a?(Hash) || value.is_a?(Array)
|
30
|
+
skip_message ||= "(can't validate complex object)"
|
31
|
+
skipped = true
|
32
|
+
end
|
33
|
+
|
34
|
+
if skip_keys.include?(key)
|
35
|
+
skip_message ||= "(skip_keys in resource definition)"
|
36
|
+
skipped = true
|
37
|
+
else
|
38
|
+
if !resource.key?(key)
|
39
|
+
bad "#{key} (missing key)", indent: 4
|
40
|
+
|
41
|
+
failure = true
|
42
|
+
next
|
43
|
+
end
|
44
|
+
|
45
|
+
if value != resource[key]
|
46
|
+
bad "#{key} (\"#{value.to_s.truncate(30)}\" != \"#{resource[key].to_s.truncate(30)}\")", indent: 4
|
47
|
+
|
48
|
+
failure = true
|
49
|
+
next
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
message = "#{key}" " (#{value.to_s.truncate(60)})"
|
54
|
+
message += " [skipped]" if skipped
|
55
|
+
message += " #{skip_message}" if skip_message
|
56
|
+
|
57
|
+
good message, indent: 4
|
58
|
+
end
|
59
|
+
|
60
|
+
next unless failure
|
61
|
+
|
62
|
+
info
|
63
|
+
info "local config:"
|
64
|
+
pp local_config.sort.to_h
|
65
|
+
info
|
66
|
+
|
67
|
+
info "remote config:"
|
68
|
+
pp resource.sort.to_h
|
69
|
+
info
|
70
|
+
|
71
|
+
fatal "error: immutable remote resource differs from local definition for resource: #{region}/#{resource["name"]}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
module GClouder
|
4
|
+
module Resources
|
5
|
+
module Local
|
6
|
+
def self.included(klass)
|
7
|
+
klass.extend Local
|
8
|
+
end
|
9
|
+
|
10
|
+
# FIXME: error if path doesnt exist..
|
11
|
+
def get_section(data, path, silent: false)
|
12
|
+
path.each do |key|
|
13
|
+
return [] unless data.key?(key)
|
14
|
+
data = data[key]
|
15
|
+
end
|
16
|
+
|
17
|
+
data
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
module Global
|
22
|
+
include GClouder::Logging
|
23
|
+
include GClouder::Config::Project
|
24
|
+
include GClouder::Config::CLIArgs
|
25
|
+
include Local
|
26
|
+
|
27
|
+
def self.instances(path: [])
|
28
|
+
data = get_section(project, path)
|
29
|
+
|
30
|
+
return {} if data.empty?
|
31
|
+
|
32
|
+
{ "global" => data }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
module Region
|
37
|
+
include GClouder::Logging
|
38
|
+
include GClouder::Helpers
|
39
|
+
include GClouder::Config::Project
|
40
|
+
include GClouder::Config::CLIArgs
|
41
|
+
include Local
|
42
|
+
|
43
|
+
def self.instances(path: [])
|
44
|
+
return {} unless project.key?("regions")
|
45
|
+
|
46
|
+
data = project["regions"].each_with_object({}) do |(region, region_config), instances|
|
47
|
+
instances[region] ||= []
|
48
|
+
|
49
|
+
data = get_section(region_config, path, silent: true)
|
50
|
+
|
51
|
+
data.each do |instance|
|
52
|
+
if GClouder::Config::Defaults.section?(path)
|
53
|
+
defaults = to_deep_merge_hash(GClouder::Config::Defaults.section(path))
|
54
|
+
instance = defaults.deep_merge(instance)
|
55
|
+
end
|
56
|
+
|
57
|
+
instances[region] << instance
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
data.delete_if { |_k, v| v.empty? }
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# FIXME: should this be split out into a separate module that deals with remote state?
|
66
|
+
module Remote
|
67
|
+
def self.instances(path: [], ignore_keys: [], skip_instances: {}, args: nil)
|
68
|
+
resource_name = path.join(" ").to_sym
|
69
|
+
|
70
|
+
Resource.list(resource_name, args).each_with_object({}) do |resource, collection|
|
71
|
+
skip = false
|
72
|
+
|
73
|
+
skip_instances.each do |key, value|
|
74
|
+
next unless resource.key?(key)
|
75
|
+
if resource[key] =~ value
|
76
|
+
skip = true
|
77
|
+
break
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
next if skip
|
82
|
+
|
83
|
+
YAML.load_file("assets/mappings/file.yml")
|
84
|
+
|
85
|
+
# FIXME: this is so keys with partial matches work..
|
86
|
+
file_mapping_key = path.join("::")
|
87
|
+
file_mapping = YAML.load_file("assets/mappings/file.yml").fetch(file_mapping_key, nil)
|
88
|
+
|
89
|
+
resource_representation_path = file_mapping.nil? ? path : file_mapping
|
90
|
+
|
91
|
+
#ap GClouder::Config::ResourceRepresentations.properties
|
92
|
+
|
93
|
+
# contains list of non-output-only remote properties
|
94
|
+
resource_representation = GClouder::Config::ResourceRepresentations.section(resource_representation_path)
|
95
|
+
|
96
|
+
#ap resource_representation
|
97
|
+
|
98
|
+
# FIXME: partial key matches here are bad.. i.e: [compute, networks] matches [compute, networks, subnetworks]
|
99
|
+
# maps remote property names back to arguments
|
100
|
+
property_mappings_key = path.join("::")
|
101
|
+
property_mappings = YAML.load_file("assets/mappings/property.yml").fetch(property_mappings_key, [])
|
102
|
+
|
103
|
+
# Assume global, unless we can find or infer a region...
|
104
|
+
region = "global"
|
105
|
+
region = resource["region"] if resource.key?("region")
|
106
|
+
zone = resource["zone"] if !resource.key?("region") && resource.key?("zone")
|
107
|
+
|
108
|
+
if resource.key?("selfLink") && resource["selfLink"].match(/zones\//) && !zone
|
109
|
+
zone = resource["selfLink"].match(/zones\/([^\/]+)/)[1]
|
110
|
+
end
|
111
|
+
|
112
|
+
if zone
|
113
|
+
resource["zone"] = zone
|
114
|
+
region = zone.sub(/-[a-z]$/, "")
|
115
|
+
end
|
116
|
+
|
117
|
+
# 1: convert key names to snake_case
|
118
|
+
resource = resource.to_snake_keys
|
119
|
+
|
120
|
+
# 2: delete any keys not in resource_representations (because they're output only)
|
121
|
+
# FIXME: warn about deleting keys?
|
122
|
+
resource.delete_if do |key, _value|
|
123
|
+
resource_representation[key] == "OutputOnly" && key != "name"
|
124
|
+
end
|
125
|
+
|
126
|
+
# 3: convert the names of any keys using the mappings file
|
127
|
+
property_mappings.each do |argument, resource_representation|
|
128
|
+
# FIXME: don't overwrite arguments..
|
129
|
+
resource[argument] = resource.dig(*resource_representation)
|
130
|
+
resource.delete(resource_representation)
|
131
|
+
end
|
132
|
+
|
133
|
+
ignore_keys.each do |key|
|
134
|
+
next unless resource.key?(key)
|
135
|
+
resource.delete(key)
|
136
|
+
end
|
137
|
+
|
138
|
+
# ?: if there are any keys *not* in the resource_representations file then we have a problem
|
139
|
+
|
140
|
+
# ?: if there are any keys which dont match an argument, then we have a problem
|
141
|
+
|
142
|
+
collection[region] ||= []
|
143
|
+
collection[region] << resource
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
|
5
|
+
module GClouder
|
6
|
+
module Shell
|
7
|
+
include GClouder::Logging
|
8
|
+
|
9
|
+
def self.included(klass)
|
10
|
+
klass.extend Shell
|
11
|
+
end
|
12
|
+
|
13
|
+
def shell(command, failure: true, silent: false)
|
14
|
+
stdout, stderr, status, failed = run(command)
|
15
|
+
|
16
|
+
if (GClouder.cli_args[:debug] || failed) && !silent
|
17
|
+
header(command)
|
18
|
+
dump_fds(stdout, stderr)
|
19
|
+
end
|
20
|
+
|
21
|
+
if !failed && silent
|
22
|
+
return true
|
23
|
+
end
|
24
|
+
|
25
|
+
if failed && !failure
|
26
|
+
return false
|
27
|
+
end
|
28
|
+
|
29
|
+
if failed && silent
|
30
|
+
return false
|
31
|
+
end
|
32
|
+
|
33
|
+
if failed && !silent
|
34
|
+
footer(status)
|
35
|
+
end
|
36
|
+
|
37
|
+
if silent
|
38
|
+
return
|
39
|
+
end
|
40
|
+
|
41
|
+
stdout
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def header(command)
|
47
|
+
info
|
48
|
+
info "# #{command}"
|
49
|
+
end
|
50
|
+
|
51
|
+
def dump_fds(stdout, stderr)
|
52
|
+
dump(stdout)
|
53
|
+
dump(stderr)
|
54
|
+
end
|
55
|
+
|
56
|
+
def dump(fd)
|
57
|
+
return if fd.empty?
|
58
|
+
info fd
|
59
|
+
end
|
60
|
+
|
61
|
+
def footer(status)
|
62
|
+
fatal "there was an error running the previous shell command which exited with non-0: #{status}"
|
63
|
+
end
|
64
|
+
|
65
|
+
def run(command)
|
66
|
+
stdout, stderr, status = Open3.capture3(command)
|
67
|
+
failed = status.to_i > 0
|
68
|
+
[stdout, stderr, status, failed]
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|