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