googleauth 0.9.0 → 0.13.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 +4 -4
- data/.kokoro/continuous/linux.cfg +12 -2
- data/.kokoro/continuous/osx.cfg +5 -0
- data/.kokoro/continuous/post.cfg +30 -0
- data/.kokoro/continuous/windows.cfg +10 -0
- data/.kokoro/presubmit/linux.cfg +11 -1
- data/.kokoro/presubmit/osx.cfg +5 -0
- data/.kokoro/presubmit/windows.cfg +10 -0
- data/.kokoro/release.cfg +42 -1
- data/.repo-metadata.json +5 -0
- data/.rubocop.yml +12 -35
- data/CHANGELOG.md +32 -0
- data/Gemfile +8 -3
- data/README.md +7 -11
- data/Rakefile +48 -5
- data/googleauth.gemspec +7 -4
- data/integration/helper.rb +31 -0
- data/integration/id_tokens/key_source_test.rb +74 -0
- data/lib/googleauth.rb +1 -0
- data/lib/googleauth/application_default.rb +9 -9
- data/lib/googleauth/compute_engine.rb +30 -27
- data/lib/googleauth/credentials.rb +92 -22
- data/lib/googleauth/credentials_loader.rb +14 -15
- data/lib/googleauth/id_tokens.rb +233 -0
- data/lib/googleauth/id_tokens/errors.rb +71 -0
- data/lib/googleauth/id_tokens/key_sources.rb +394 -0
- data/lib/googleauth/id_tokens/verifier.rb +144 -0
- data/lib/googleauth/json_key_reader.rb +6 -2
- data/lib/googleauth/service_account.rb +16 -7
- data/lib/googleauth/signet.rb +8 -6
- data/lib/googleauth/user_authorizer.rb +8 -3
- data/lib/googleauth/user_refresh.rb +1 -1
- data/lib/googleauth/version.rb +1 -1
- data/lib/googleauth/web_user_authorizer.rb +1 -1
- data/rakelib/devsite_builder.rb +45 -0
- data/rakelib/link_checker.rb +64 -0
- data/rakelib/repo_metadata.rb +59 -0
- data/spec/googleauth/apply_auth_examples.rb +28 -5
- data/spec/googleauth/compute_engine_spec.rb +37 -13
- data/spec/googleauth/credentials_spec.rb +25 -6
- data/spec/googleauth/service_account_spec.rb +23 -16
- data/spec/googleauth/signet_spec.rb +15 -7
- data/spec/googleauth/user_authorizer_spec.rb +21 -1
- data/spec/googleauth/user_refresh_spec.rb +1 -1
- data/test/helper.rb +33 -0
- data/test/id_tokens/key_sources_test.rb +240 -0
- data/test/id_tokens/verifier_test.rb +269 -0
- metadata +45 -12
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright 2020 Google LLC
|
4
|
+
#
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
6
|
+
# modification, are permitted provided that the following conditions are
|
7
|
+
# met:
|
8
|
+
#
|
9
|
+
# * Redistributions of source code must retain the above copyright
|
10
|
+
# notice, this list of conditions and the following disclaimer.
|
11
|
+
# * Redistributions in binary form must reproduce the above
|
12
|
+
# copyright notice, this list of conditions and the following disclaimer
|
13
|
+
# in the documentation and/or other materials provided with the
|
14
|
+
# distribution.
|
15
|
+
# * Neither the name of Google Inc. nor the names of its
|
16
|
+
# contributors may be used to endorse or promote products derived from
|
17
|
+
# this software without specific prior written permission.
|
18
|
+
#
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
20
|
+
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
21
|
+
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
22
|
+
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
23
|
+
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
24
|
+
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
25
|
+
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
26
|
+
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
27
|
+
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
28
|
+
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
29
|
+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
30
|
+
|
31
|
+
require "jwt"
|
32
|
+
|
33
|
+
module Google
|
34
|
+
module Auth
|
35
|
+
module IDTokens
|
36
|
+
##
|
37
|
+
# An object that can verify ID tokens.
|
38
|
+
#
|
39
|
+
# A verifier maintains a set of default settings, including the key
|
40
|
+
# source and fields to verify. However, individual verification calls can
|
41
|
+
# override any of these settings.
|
42
|
+
#
|
43
|
+
class Verifier
|
44
|
+
##
|
45
|
+
# Create a verifier.
|
46
|
+
#
|
47
|
+
# @param key_source [key source] The default key source to use. All
|
48
|
+
# verification calls must have a key source, so if no default key
|
49
|
+
# source is provided here, then calls to {#verify} _must_ provide
|
50
|
+
# a key source.
|
51
|
+
# @param aud [String,nil] The default audience (`aud`) check, or `nil`
|
52
|
+
# for no check.
|
53
|
+
# @param azp [String,nil] The default authorized party (`azp`) check,
|
54
|
+
# or `nil` for no check.
|
55
|
+
# @param iss [String,nil] The default issuer (`iss`) check, or `nil`
|
56
|
+
# for no check.
|
57
|
+
#
|
58
|
+
def initialize key_source: nil,
|
59
|
+
aud: nil,
|
60
|
+
azp: nil,
|
61
|
+
iss: nil
|
62
|
+
@key_source = key_source
|
63
|
+
@aud = aud
|
64
|
+
@azp = azp
|
65
|
+
@iss = iss
|
66
|
+
end
|
67
|
+
|
68
|
+
##
|
69
|
+
# Verify the given token.
|
70
|
+
#
|
71
|
+
# @param token [String] the ID token to verify.
|
72
|
+
# @param key_source [key source] If given, override the key source.
|
73
|
+
# @param aud [String,nil] If given, override the `aud` check.
|
74
|
+
# @param azp [String,nil] If given, override the `azp` check.
|
75
|
+
# @param iss [String,nil] If given, override the `iss` check.
|
76
|
+
#
|
77
|
+
# @return [Hash] the decoded payload, if verification succeeded.
|
78
|
+
# @raise [KeySourceError] if the key source failed to obtain public keys
|
79
|
+
# @raise [VerificationError] if the token verification failed.
|
80
|
+
# Additional data may be available in the error subclass and message.
|
81
|
+
#
|
82
|
+
def verify token,
|
83
|
+
key_source: :default,
|
84
|
+
aud: :default,
|
85
|
+
azp: :default,
|
86
|
+
iss: :default
|
87
|
+
key_source = @key_source if key_source == :default
|
88
|
+
aud = @aud if aud == :default
|
89
|
+
azp = @azp if azp == :default
|
90
|
+
iss = @iss if iss == :default
|
91
|
+
|
92
|
+
raise KeySourceError, "No key sources" unless key_source
|
93
|
+
keys = key_source.current_keys
|
94
|
+
payload = decode_token token, keys, aud, azp, iss
|
95
|
+
unless payload
|
96
|
+
keys = key_source.refresh_keys
|
97
|
+
payload = decode_token token, keys, aud, azp, iss
|
98
|
+
end
|
99
|
+
raise SignatureError, "Token not verified as issued by Google" unless payload
|
100
|
+
payload
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def decode_token token, keys, aud, azp, iss
|
106
|
+
payload = nil
|
107
|
+
keys.find do |key|
|
108
|
+
begin
|
109
|
+
options = { algorithms: key.algorithm }
|
110
|
+
decoded_token = JWT.decode token, key.key, true, options
|
111
|
+
payload = decoded_token.first
|
112
|
+
rescue JWT::ExpiredSignature
|
113
|
+
raise ExpiredTokenError, "Token signature is expired"
|
114
|
+
rescue JWT::DecodeError
|
115
|
+
nil # Try the next key
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
normalize_and_verify_payload payload, aud, azp, iss
|
120
|
+
end
|
121
|
+
|
122
|
+
def normalize_and_verify_payload payload, aud, azp, iss
|
123
|
+
return nil unless payload
|
124
|
+
|
125
|
+
# Map the legacy "cid" claim to the canonical "azp"
|
126
|
+
payload["azp"] ||= payload["cid"] if payload.key? "cid"
|
127
|
+
|
128
|
+
# Payload content validation
|
129
|
+
if aud && (Array(aud) & Array(payload["aud"])).empty?
|
130
|
+
raise AudienceMismatchError, "Token aud mismatch: #{payload['aud']}"
|
131
|
+
end
|
132
|
+
if azp && (Array(azp) & Array(payload["azp"])).empty?
|
133
|
+
raise AuthorizedPartyMismatchError, "Token azp mismatch: #{payload['azp']}"
|
134
|
+
end
|
135
|
+
if iss && (Array(iss) & Array(payload["iss"])).empty?
|
136
|
+
raise IssuerMismatchError, "Token iss mismatch: #{payload['iss']}"
|
137
|
+
end
|
138
|
+
|
139
|
+
payload
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -38,8 +38,12 @@ module Google
|
|
38
38
|
json_key = MultiJson.load json_key_io.read
|
39
39
|
raise "missing client_email" unless json_key.key? "client_email"
|
40
40
|
raise "missing private_key" unless json_key.key? "private_key"
|
41
|
-
|
42
|
-
|
41
|
+
[
|
42
|
+
json_key["private_key"],
|
43
|
+
json_key["client_email"],
|
44
|
+
json_key["project_id"],
|
45
|
+
json_key["quota_project_id"]
|
46
|
+
]
|
43
47
|
end
|
44
48
|
end
|
45
49
|
end
|
@@ -45,34 +45,40 @@ module Google
|
|
45
45
|
# from credentials from a json key file downloaded from the developer
|
46
46
|
# console (via 'Generate new Json Key').
|
47
47
|
#
|
48
|
-
# cf [Application Default Credentials](
|
48
|
+
# cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production)
|
49
49
|
class ServiceAccountCredentials < Signet::OAuth2::Client
|
50
50
|
TOKEN_CRED_URI = "https://www.googleapis.com/oauth2/v4/token".freeze
|
51
51
|
extend CredentialsLoader
|
52
52
|
extend JsonKeyReader
|
53
53
|
attr_reader :project_id
|
54
|
+
attr_reader :quota_project_id
|
54
55
|
|
55
56
|
# Creates a ServiceAccountCredentials.
|
56
57
|
#
|
57
58
|
# @param json_key_io [IO] an IO from which the JSON key can be read
|
58
59
|
# @param scope [string|array|nil] the scope(s) to access
|
59
60
|
def self.make_creds options = {}
|
60
|
-
json_key_io, scope = options.values_at :json_key_io, :scope
|
61
|
+
json_key_io, scope, target_audience = options.values_at :json_key_io, :scope, :target_audience
|
62
|
+
raise ArgumentError, "Cannot specify both scope and target_audience" if scope && target_audience
|
63
|
+
|
61
64
|
if json_key_io
|
62
|
-
private_key, client_email, project_id = read_json_key json_key_io
|
65
|
+
private_key, client_email, project_id, quota_project_id = read_json_key json_key_io
|
63
66
|
else
|
64
67
|
private_key = unescape ENV[CredentialsLoader::PRIVATE_KEY_VAR]
|
65
68
|
client_email = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
|
66
69
|
project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
|
70
|
+
quota_project_id = nil
|
67
71
|
end
|
68
72
|
project_id ||= CredentialsLoader.load_gcloud_project_id
|
69
73
|
|
70
74
|
new(token_credential_uri: TOKEN_CRED_URI,
|
71
75
|
audience: TOKEN_CRED_URI,
|
72
76
|
scope: scope,
|
77
|
+
target_audience: target_audience,
|
73
78
|
issuer: client_email,
|
74
79
|
signing_key: OpenSSL::PKey::RSA.new(private_key),
|
75
|
-
project_id: project_id
|
80
|
+
project_id: project_id,
|
81
|
+
quota_project_id: quota_project_id)
|
76
82
|
.configure_connection(options)
|
77
83
|
end
|
78
84
|
|
@@ -87,6 +93,7 @@ module Google
|
|
87
93
|
|
88
94
|
def initialize options = {}
|
89
95
|
@project_id = options[:project_id]
|
96
|
+
@quota_project_id = options[:quota_project_id]
|
90
97
|
super options
|
91
98
|
end
|
92
99
|
|
@@ -97,7 +104,7 @@ module Google
|
|
97
104
|
# authenticate instead.
|
98
105
|
def apply! a_hash, opts = {}
|
99
106
|
# Use the base implementation if scopes are set
|
100
|
-
unless scope.nil?
|
107
|
+
unless scope.nil? && target_audience.nil?
|
101
108
|
super
|
102
109
|
return
|
103
110
|
end
|
@@ -123,7 +130,7 @@ module Google
|
|
123
130
|
# console (via 'Generate new Json Key'). It is not part of any OAuth2
|
124
131
|
# flow, rather it creates a JWT and sends that as a credential.
|
125
132
|
#
|
126
|
-
# cf [Application Default Credentials](
|
133
|
+
# cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production)
|
127
134
|
class ServiceAccountJwtHeaderCredentials
|
128
135
|
JWT_AUD_URI_KEY = :jwt_aud_uri
|
129
136
|
AUTH_METADATA_KEY = Signet::OAuth2::AUTH_METADATA_KEY
|
@@ -133,6 +140,7 @@ module Google
|
|
133
140
|
extend CredentialsLoader
|
134
141
|
extend JsonKeyReader
|
135
142
|
attr_reader :project_id
|
143
|
+
attr_reader :quota_project_id
|
136
144
|
|
137
145
|
# make_creds proxies the construction of a credentials instance
|
138
146
|
#
|
@@ -151,12 +159,13 @@ module Google
|
|
151
159
|
def initialize options = {}
|
152
160
|
json_key_io = options[:json_key_io]
|
153
161
|
if json_key_io
|
154
|
-
@private_key, @issuer, @project_id =
|
162
|
+
@private_key, @issuer, @project_id, @quota_project_id =
|
155
163
|
self.class.read_json_key json_key_io
|
156
164
|
else
|
157
165
|
@private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR]
|
158
166
|
@issuer = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
|
159
167
|
@project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
|
168
|
+
@quota_project_id = nil
|
160
169
|
end
|
161
170
|
@project_id ||= CredentialsLoader.load_gcloud_project_id
|
162
171
|
@signing_key = OpenSSL::PKey::RSA.new @private_key
|
data/lib/googleauth/signet.rb
CHANGED
@@ -48,8 +48,9 @@ module Signet
|
|
48
48
|
def apply! a_hash, opts = {}
|
49
49
|
# fetch the access token there is currently not one, or if the client
|
50
50
|
# has expired
|
51
|
-
|
52
|
-
|
51
|
+
token_type = target_audience ? :id_token : :access_token
|
52
|
+
fetch_access_token! opts if send(token_type).nil? || expires_within?(60)
|
53
|
+
a_hash[AUTH_METADATA_KEY] = "Bearer #{send token_type}"
|
53
54
|
end
|
54
55
|
|
55
56
|
# Returns a clone of a_hash updated with the authentication token
|
@@ -66,7 +67,7 @@ module Signet
|
|
66
67
|
end
|
67
68
|
|
68
69
|
def on_refresh &block
|
69
|
-
@refresh_listeners
|
70
|
+
@refresh_listeners = [] unless defined? @refresh_listeners
|
70
71
|
@refresh_listeners << block
|
71
72
|
end
|
72
73
|
|
@@ -84,15 +85,16 @@ module Signet
|
|
84
85
|
end
|
85
86
|
|
86
87
|
def notify_refresh_listeners
|
87
|
-
listeners = @refresh_listeners
|
88
|
+
listeners = defined?(@refresh_listeners) ? @refresh_listeners : []
|
88
89
|
listeners.each do |block|
|
89
90
|
block.call self
|
90
91
|
end
|
91
92
|
end
|
92
93
|
|
93
94
|
def build_default_connection
|
94
|
-
|
95
|
-
|
95
|
+
if !defined?(@connection_info)
|
96
|
+
nil
|
97
|
+
elsif @connection_info.respond_to? :call
|
96
98
|
@connection_info.call
|
97
99
|
else
|
98
100
|
@connection_info
|
@@ -129,8 +129,8 @@ module Google
|
|
129
129
|
data = MultiJson.load saved_token
|
130
130
|
|
131
131
|
if data.fetch("client_id", @client_id.id) != @client_id.id
|
132
|
-
raise
|
133
|
-
|
132
|
+
raise format(MISMATCHED_CLIENT_ID_ERROR,
|
133
|
+
data["client_id"], @client_id.id)
|
134
134
|
end
|
135
135
|
|
136
136
|
credentials = UserRefreshCredentials.new(
|
@@ -271,10 +271,15 @@ module Google
|
|
271
271
|
# @return [String]
|
272
272
|
# Redirect URI
|
273
273
|
def redirect_uri_for base_url
|
274
|
-
return @callback_uri
|
274
|
+
return @callback_uri if uri_is_postmessage?(@callback_uri) || !URI(@callback_uri).scheme.nil?
|
275
275
|
raise format(MISSING_ABSOLUTE_URL_ERROR, @callback_uri) if base_url.nil? || URI(base_url).scheme.nil?
|
276
276
|
URI.join(base_url, @callback_uri).to_s
|
277
277
|
end
|
278
|
+
|
279
|
+
# Check if URI is Google's postmessage flow (not a valid redirect_uri by spec, but allowed)
|
280
|
+
def uri_is_postmessage? uri
|
281
|
+
uri.to_s.casecmp("postmessage").zero?
|
282
|
+
end
|
278
283
|
end
|
279
284
|
end
|
280
285
|
end
|
@@ -44,7 +44,7 @@ module Google
|
|
44
44
|
# 'gcloud auth login' saves a file with these contents in well known
|
45
45
|
# location
|
46
46
|
#
|
47
|
-
# cf [Application Default Credentials](
|
47
|
+
# cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production)
|
48
48
|
class UserRefreshCredentials < Signet::OAuth2::Client
|
49
49
|
TOKEN_CRED_URI = "https://oauth2.googleapis.com/token".freeze
|
50
50
|
AUTHORIZATION_URI = "https://accounts.google.com/o/oauth2/auth".freeze
|
data/lib/googleauth/version.rb
CHANGED
@@ -0,0 +1,45 @@
|
|
1
|
+
require "pathname"
|
2
|
+
|
3
|
+
require_relative "repo_metadata.rb"
|
4
|
+
|
5
|
+
class DevsiteBuilder
|
6
|
+
def initialize master_dir = "."
|
7
|
+
@master_dir = Pathname.new master_dir
|
8
|
+
@output_dir = "doc"
|
9
|
+
@metadata = RepoMetadata.from_source "#{master_dir}/.repo-metadata.json"
|
10
|
+
end
|
11
|
+
|
12
|
+
def build
|
13
|
+
FileUtils.remove_dir @output_dir if Dir.exist? @output_dir
|
14
|
+
markup = "--markup markdown"
|
15
|
+
|
16
|
+
Dir.chdir @master_dir do
|
17
|
+
cmds = ["-o #{@output_dir}", markup]
|
18
|
+
cmd "yard --verbose #{cmds.join ' '}"
|
19
|
+
end
|
20
|
+
@metadata.build @master_dir + @output_dir
|
21
|
+
end
|
22
|
+
|
23
|
+
def upload
|
24
|
+
Dir.chdir @output_dir do
|
25
|
+
opts = [
|
26
|
+
"--credentials=#{ENV['KOKORO_KEYSTORE_DIR']}/73713_docuploader_service_account",
|
27
|
+
"--staging-bucket=#{ENV.fetch 'STAGING_BUCKET', 'docs-staging'}",
|
28
|
+
"--metadata-file=./docs.metadata"
|
29
|
+
]
|
30
|
+
cmd "python3 -m docuploader upload . #{opts.join ' '}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def publish
|
35
|
+
build
|
36
|
+
upload
|
37
|
+
end
|
38
|
+
|
39
|
+
def cmd line
|
40
|
+
puts line
|
41
|
+
output = `#{line}`
|
42
|
+
puts output
|
43
|
+
output
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require "open3"
|
2
|
+
|
3
|
+
class LinkChecker
|
4
|
+
def initialize
|
5
|
+
@failed = false
|
6
|
+
end
|
7
|
+
|
8
|
+
def run
|
9
|
+
job_info
|
10
|
+
git_commit = ENV.fetch "KOKORO_GITHUB_COMMIT", "master"
|
11
|
+
|
12
|
+
markdown_files = Dir.glob "**/*.md"
|
13
|
+
broken_markdown_links = check_links markdown_files,
|
14
|
+
"https://github.com/googleapis/google-auth-library-ruby/tree/#{git_commit}",
|
15
|
+
" --skip '^(?!(\\Wruby.*google|.*google.*\\Wruby|.*cloud\\.google\\.com))'"
|
16
|
+
|
17
|
+
broken_devsite_links = check_links ["googleauth"],
|
18
|
+
"https://googleapis.dev/ruby",
|
19
|
+
"/latest/ --recurse --skip https:.*github.*"
|
20
|
+
|
21
|
+
puts_broken_links broken_markdown_links
|
22
|
+
puts_broken_links broken_devsite_links
|
23
|
+
end
|
24
|
+
|
25
|
+
def check_links location_list, base, tail
|
26
|
+
broken_links = Hash.new { |h, k| h[k] = [] }
|
27
|
+
location_list.each do |location|
|
28
|
+
out, err, st = Open3.capture3 "npx linkinator #{base}/#{location}#{tail}"
|
29
|
+
puts out
|
30
|
+
unless st.to_i.zero?
|
31
|
+
@failed = true
|
32
|
+
puts err
|
33
|
+
end
|
34
|
+
checked_links = out.split "\n"
|
35
|
+
checked_links.select! { |link| link =~ /\[\d+\]/ && !link.include?("[200]") }
|
36
|
+
unless checked_links.empty?
|
37
|
+
@failed = true
|
38
|
+
broken_links[location] += checked_links
|
39
|
+
end
|
40
|
+
end
|
41
|
+
broken_links
|
42
|
+
end
|
43
|
+
|
44
|
+
def puts_broken_links link_hash
|
45
|
+
link_hash.each do |location, links|
|
46
|
+
puts "#{location} contains the following broken links:"
|
47
|
+
links.each { |link| puts " #{link}" }
|
48
|
+
puts ""
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def job_info
|
53
|
+
line_length = "Using Ruby - #{RUBY_VERSION}".length + 8
|
54
|
+
puts ""
|
55
|
+
puts "#" * line_length
|
56
|
+
puts "### Using Ruby - #{RUBY_VERSION} ###"
|
57
|
+
puts "#" * line_length
|
58
|
+
puts ""
|
59
|
+
end
|
60
|
+
|
61
|
+
def exit_status
|
62
|
+
@failed ? 1 : 0
|
63
|
+
end
|
64
|
+
end
|