p4_web_api 2014.2.0.pre1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/p4_web_api +4 -0
- data/lib/p4_web_api.rb +700 -0
- data/lib/p4_web_api/auth.rb +268 -0
- data/lib/p4_web_api/p4_error.rb +36 -0
- data/lib/p4_web_api/p4_util.rb +417 -0
- data/lib/p4_web_api/version.rb +6 -0
- metadata +249 -0
@@ -0,0 +1,268 @@
|
|
1
|
+
# Copyright (c) 2014 Perforce Software, Inc. All rights reserved.
|
2
|
+
# vim:ts=2:sw=2:et:si:ai:
|
3
|
+
|
4
|
+
require 'fileutils'
|
5
|
+
require 'json'
|
6
|
+
require 'pathname'
|
7
|
+
require 'securerandom'
|
8
|
+
|
9
|
+
require_relative 'p4_util'
|
10
|
+
|
11
|
+
module P4WebAPI
|
12
|
+
# Sinatra Middleware component to take the HTTP Basic authentication request
|
13
|
+
# and pass that along to the internal P4WebAPI::Auth class methods
|
14
|
+
class AuthMiddleware
|
15
|
+
def initialize(app, options = {})
|
16
|
+
@app = app
|
17
|
+
@unauthenticated_paths = []
|
18
|
+
if options[:unauthenticated_paths]
|
19
|
+
@unauthenticated_paths = options[:unauthenticated_paths]
|
20
|
+
end
|
21
|
+
@settings = options[:settings]
|
22
|
+
end
|
23
|
+
|
24
|
+
def call(env)
|
25
|
+
return @app.call(env) if unauthenticated_path?(env)
|
26
|
+
|
27
|
+
auth = Rack::Auth::Basic::Request.new(env)
|
28
|
+
if auth.provided? &&
|
29
|
+
auth.basic? &&
|
30
|
+
P4WebAPI::Auth.credentials_valid?(auth.credentials, @settings)
|
31
|
+
env['AUTH_CREDENTIALS'] = auth.credentials
|
32
|
+
return @app.call(env)
|
33
|
+
end
|
34
|
+
|
35
|
+
unauthenticated_error
|
36
|
+
end
|
37
|
+
|
38
|
+
def unauthenticated_path?(env)
|
39
|
+
@unauthenticated_paths.include?(env['PATH_INFO'])
|
40
|
+
end
|
41
|
+
|
42
|
+
def unauthenticated_error
|
43
|
+
[
|
44
|
+
403,
|
45
|
+
{ 'Content-Type' => 'text/plain',
|
46
|
+
'Content-Length' => '0',
|
47
|
+
'WWW-Authenticate' => 'Basic realm="Perforce Web API"' },
|
48
|
+
[]
|
49
|
+
]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# "Session" handling that basically uses Perforce on the backend and stores
|
54
|
+
# the p4 ticket in a GUID file. We then pass the GUID around 'publicly' since
|
55
|
+
# that's really only relevant to this one instance. We can then fetch the
|
56
|
+
# p4 ticket and pass that to other services internally without "exposing" it.
|
57
|
+
module Auth
|
58
|
+
# Double checks that really only the current user can write to the token
|
59
|
+
# directory. If it doesn't exist, we just print warnings to stdout.
|
60
|
+
#
|
61
|
+
# These checks are only intended to work on Linux and OS X.
|
62
|
+
def self.validate_token_dir(token_dir)
|
63
|
+
warn_if_tmp_dir(token_dir)
|
64
|
+
|
65
|
+
return unless File.exist?(token_dir)
|
66
|
+
|
67
|
+
if !File.owned?(token_dir)
|
68
|
+
puts "The current user does not own security token dir: #{token_dir}"
|
69
|
+
else
|
70
|
+
mode = File.stat(token_dir).mode
|
71
|
+
warn_illegal_privileges(mode, token_dir)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# If the login/password combination is valid, we'll create a new session and
|
76
|
+
# return the session ID (which is just a GUID).
|
77
|
+
#
|
78
|
+
# This assumes that the p4 connection already has the user name set.
|
79
|
+
#
|
80
|
+
# If the password is a ticket, that'll be stored as is. If it's a password
|
81
|
+
# (currently determined if it's not a 32 character alphanumeric string)
|
82
|
+
# we'll create a ticket and store that.
|
83
|
+
#
|
84
|
+
# Settings is the Sinatra application configuration.
|
85
|
+
def self.create_session(p4, password, settings)
|
86
|
+
ticket = nil
|
87
|
+
|
88
|
+
if P4Util.p4_ticket?(password)
|
89
|
+
ticket = password
|
90
|
+
else
|
91
|
+
ticket = ticket_from_login(p4)
|
92
|
+
end
|
93
|
+
|
94
|
+
return if ticket.nil?
|
95
|
+
|
96
|
+
results = p4.run_user('-o')
|
97
|
+
|
98
|
+
save_token(user_info(p4, results, ticket), settings)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Saves the information stored in the user_info hash to a new randomly
|
102
|
+
# generated ticket file, and returns that new ticket file's name.
|
103
|
+
#
|
104
|
+
# user_info should have the following values:
|
105
|
+
# * login
|
106
|
+
# * email
|
107
|
+
# * full_name
|
108
|
+
# * ticket
|
109
|
+
def self.save_token(user_info, settings)
|
110
|
+
unless File.directory?(settings.token_path)
|
111
|
+
Pathname.new(settings.token_path).mkpath
|
112
|
+
FileUtils.chmod(0700, settings.token_path)
|
113
|
+
end
|
114
|
+
|
115
|
+
token = SecureRandom.uuid
|
116
|
+
token_path = Pathname.new(settings.token_path) + token
|
117
|
+
|
118
|
+
File.open(token_path, 'w', 0600) do |file|
|
119
|
+
file.write(JSON.generate(user_info))
|
120
|
+
end
|
121
|
+
|
122
|
+
token
|
123
|
+
end
|
124
|
+
|
125
|
+
# Removes the session file
|
126
|
+
#
|
127
|
+
# Assumes blindly that we're supposed to be able to do this. The caller
|
128
|
+
# should ensure that the token is deletable.
|
129
|
+
def self.delete_session(token, settings)
|
130
|
+
token_path = Pathname.new(settings.token_path) + token
|
131
|
+
File.delete(token_path) if File.exist?(token_path)
|
132
|
+
end
|
133
|
+
|
134
|
+
# Takes an array with [login, password], and returns true if the password
|
135
|
+
# is a legal token for that login, or looks like a p4 ticket.
|
136
|
+
def self.credentials_valid?(credentials, settings)
|
137
|
+
login = credentials.first
|
138
|
+
password = credentials.last
|
139
|
+
# Can't really determine much at this point, just return true. If we're
|
140
|
+
# a token, it's a UUID and we'll learn more about that later.
|
141
|
+
return true unless P4Util.uuid?(password)
|
142
|
+
|
143
|
+
user_info = read_token(password, settings)
|
144
|
+
if user_info
|
145
|
+
return user_info['login'] == login
|
146
|
+
else
|
147
|
+
false
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def self.read_token(token, settings)
|
152
|
+
token_path = Pathname.new(settings.token_path) + token
|
153
|
+
if File.exist?(token_path)
|
154
|
+
File.open(token_path, 'r') do |file|
|
155
|
+
return JSON.parse(file.read)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
nil
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
def self.warn_if_tmp_dir(token_dir)
|
164
|
+
if token_dir.start_with?('/tmp')
|
165
|
+
puts "Your token directory is using the default '/tmp' location, "\
|
166
|
+
'please reconfigure to a reliable location'
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def self.warn_illegal_privileges(mode, token_dir)
|
171
|
+
warn_unless_user_rwx(mode, token_dir)
|
172
|
+
warn_no_group_write(mode, token_dir)
|
173
|
+
warn_no_group_read(mode, token_dir)
|
174
|
+
warn_no_group_execute(mode, token_dir)
|
175
|
+
warn_no_other_write(mode, token_dir)
|
176
|
+
warn_no_other_read(mode, token_dir)
|
177
|
+
warn_no_other_execute(mode, token_dir)
|
178
|
+
end
|
179
|
+
|
180
|
+
# Check owner read/write/execute - should all be there
|
181
|
+
def self.warn_unless_user_rwx(mode, token_dir)
|
182
|
+
unless (mode & 0400 == 0400) &&
|
183
|
+
(mode & 0200 == 0200) &&
|
184
|
+
(mode & 0100 == 0100)
|
185
|
+
puts "The token_path '#{token_dir}' should allow the owner read, "\
|
186
|
+
'write and execute privileges'
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def self.warn_no_group_write(mode, token_dir)
|
191
|
+
if (mode & 0040 == 0040)
|
192
|
+
puts "The token_path '#{token_dir}' should not have group write access"
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
def self.warn_no_group_read(mode, token_dir)
|
197
|
+
if (mode & 0020 == 0020)
|
198
|
+
puts "The token_path '#{token_dir}' should not have group read access"
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
def self.warn_no_group_execute(mode, token_dir)
|
203
|
+
if (mode & 0010 == 0010)
|
204
|
+
puts "The token_path '#{token_dir}' should not have group "\
|
205
|
+
'execute access'
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def self.warn_no_other_write(mode, token_dir)
|
210
|
+
if (mode & 0004 == 0004)
|
211
|
+
puts "The token_path '#{token_dir}' should not have other write access"
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def self.warn_no_other_read(mode, token_dir)
|
216
|
+
if (mode & 0002 == 0002)
|
217
|
+
puts "The token_path '#{token_dir}' should not have other read access"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
def self.warn_no_other_execute(mode, token_dir)
|
222
|
+
if (mode & 0001 == 0001)
|
223
|
+
puts "The token_path '#{token_dir}' should not have other "\
|
224
|
+
'execute access'
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# We want special error handling here to return 4xx codes instead of 5xx
|
229
|
+
# in the face of an invalid password. Temporarily drop to lowest
|
230
|
+
# exception level, and just return nil when login doesn't work.
|
231
|
+
def self.ticket_from_login(p4)
|
232
|
+
results = nil
|
233
|
+
p4.at_exception_level(P4::RAISE_NONE) do
|
234
|
+
results = p4.run_login('-p')
|
235
|
+
end
|
236
|
+
|
237
|
+
auth_ok = raise_unless_auth_error(p4)
|
238
|
+
|
239
|
+
if !auth_ok
|
240
|
+
nil
|
241
|
+
else
|
242
|
+
p4.password = results[0]
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def self.raise_unless_auth_error(p4)
|
247
|
+
if P4Util.error?(p4)
|
248
|
+
msg = p4.messages[0]
|
249
|
+
if msg.msgid == 7205 || # invalid user
|
250
|
+
msg.msgid == 7206 # invalid password
|
251
|
+
return false
|
252
|
+
else
|
253
|
+
P4Util.raise_error(p4)
|
254
|
+
end
|
255
|
+
end
|
256
|
+
true
|
257
|
+
end
|
258
|
+
|
259
|
+
def self.user_info(p4, results, ticket)
|
260
|
+
{
|
261
|
+
login: p4.user,
|
262
|
+
email: results[0]['Email'],
|
263
|
+
full_name: results[0]['FullName'],
|
264
|
+
ticket: ticket
|
265
|
+
}
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# vim:ts=2:sw=2:et:si:ai:
|
2
|
+
# The API sends back messages via the p4.messages attribute if there was an
|
3
|
+
# error with the last command. We will often convert that to this exception,
|
4
|
+
# which will be formatted into JSON.
|
5
|
+
require 'P4'
|
6
|
+
|
7
|
+
module P4WebAPI
|
8
|
+
# Any error we get from the Perforce server is generally encapsulated in
|
9
|
+
# a P4Error, which allows us to get some basic diagnostic information from
|
10
|
+
# the Perforce server out to the user.
|
11
|
+
class P4Error < RuntimeError
|
12
|
+
# All error codes must be greater than this number.
|
13
|
+
ERROR_CODE_BASE = 15_360
|
14
|
+
|
15
|
+
attr_accessor :message_code, :message_severity, :message_text
|
16
|
+
|
17
|
+
# Class method to create a valid P4Error object from a simple
|
18
|
+
# string. Used when we get errors for which no P4::Message object
|
19
|
+
# is available.
|
20
|
+
def self.default_error(message_text)
|
21
|
+
P4Error.new(ERROR_CODE_BASE,
|
22
|
+
P4::E_FAILED,
|
23
|
+
message_text)
|
24
|
+
end
|
25
|
+
|
26
|
+
def initialize(code, severity, text)
|
27
|
+
@message_code = code
|
28
|
+
@message_severity = severity
|
29
|
+
@message_text = text
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_s
|
33
|
+
@message_text
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,417 @@
|
|
1
|
+
# Copyright (c) 2014 Perforce Software, Inc. All rights reserved.
|
2
|
+
#
|
3
|
+
# vim:ts=2:sw=2:et:si:ai:
|
4
|
+
#
|
5
|
+
# p4_util.rb
|
6
|
+
#
|
7
|
+
#
|
8
|
+
|
9
|
+
require 'date'
|
10
|
+
require 'ostruct'
|
11
|
+
require 'P4'
|
12
|
+
|
13
|
+
require_relative 'p4_error'
|
14
|
+
|
15
|
+
module P4WebAPI
|
16
|
+
# Namespace for p4ruby conventions used in the P4 Web API.
|
17
|
+
module P4Util
|
18
|
+
PROPERTIES = [
|
19
|
+
:password, :port, :user, :api_level, :charset, :client,
|
20
|
+
:host, :handler, :maxlocktime, :maxresults, :maxscanrows, :prog,
|
21
|
+
:ticketfile
|
22
|
+
]
|
23
|
+
|
24
|
+
# Creates your p4 connection using some common forms.
|
25
|
+
#
|
26
|
+
# If you call open with a block, this will call connect before your block
|
27
|
+
# executes, and disconnect afterwards.
|
28
|
+
#
|
29
|
+
# If you do not call open with a block, it is up to the caller to connect
|
30
|
+
# and disconnect. (It's assumed you are calling w/o a block because you want
|
31
|
+
# to manage when the connection actually needs to happen.)
|
32
|
+
def self.open(options = {})
|
33
|
+
p4 = create_p4(options)
|
34
|
+
|
35
|
+
# Again, if we're calling using the block, we'll connect and disconnect.
|
36
|
+
# Otherwise, just return the created p4 object.
|
37
|
+
if block_given?
|
38
|
+
begin
|
39
|
+
p4.connect
|
40
|
+
yield p4
|
41
|
+
rescue P4Exception
|
42
|
+
raise make_p4_error(p4)
|
43
|
+
end
|
44
|
+
else
|
45
|
+
return p4
|
46
|
+
end
|
47
|
+
ensure
|
48
|
+
p4.disconnect if block_given? && p4 && p4.connected?
|
49
|
+
end
|
50
|
+
|
51
|
+
# Check for P4 errors
|
52
|
+
def self.error?(p4)
|
53
|
+
!p4.errors.empty?
|
54
|
+
end
|
55
|
+
|
56
|
+
# Raise an exception if necessary on a P4 Error
|
57
|
+
def self.raise_error(p4)
|
58
|
+
err = p4.messages.find { |m| m.severity > 2 }
|
59
|
+
fail P4WebAPI::P4Error.new(err.msgid, err.severity, err.to_s)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Returns true if the string looks like a Perforce authentication ticket.
|
63
|
+
def self.p4_ticket?(str)
|
64
|
+
/^[a-zA-Z0-9]{32,}$/.match(str) != nil
|
65
|
+
end
|
66
|
+
|
67
|
+
# We only count uuids that were returned via SecureRandom.uuid used to
|
68
|
+
# generate internal security tokens.
|
69
|
+
def self.uuid?(str)
|
70
|
+
/^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/
|
71
|
+
.match(str)
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.singular(plural)
|
75
|
+
matches = {
|
76
|
+
branches: 'branch',
|
77
|
+
clients: 'client',
|
78
|
+
depots: 'depot',
|
79
|
+
groups: 'group',
|
80
|
+
jobs: 'job',
|
81
|
+
labels: 'label',
|
82
|
+
protects: 'protect',
|
83
|
+
servers: 'server',
|
84
|
+
streams: 'stream',
|
85
|
+
triggers: 'trigger',
|
86
|
+
users: 'user'
|
87
|
+
}
|
88
|
+
matches[plural.to_sym]
|
89
|
+
end
|
90
|
+
|
91
|
+
# Data 'normalization'
|
92
|
+
#
|
93
|
+
# A very annoying aspect of our tagged output is that it often is *slightly*
|
94
|
+
# different between 'plural' and spec forms. This logic is made available
|
95
|
+
# as an option in this API
|
96
|
+
|
97
|
+
BRANCHES_MAP = {
|
98
|
+
'branch' => 'Branch'
|
99
|
+
}
|
100
|
+
BRANCHES_DATES = %w(Update Access)
|
101
|
+
|
102
|
+
CHANGES_MAP = {
|
103
|
+
'path' => 'Path',
|
104
|
+
'change' => 'Change',
|
105
|
+
'time' => 'Date',
|
106
|
+
'client' => 'Client',
|
107
|
+
'user' => 'User',
|
108
|
+
'status' => 'Status',
|
109
|
+
'type' => 'Type',
|
110
|
+
'changeType' => 'Type',
|
111
|
+
'desc' => 'Description'
|
112
|
+
}
|
113
|
+
DESCRIBES_MAP = CHANGES_MAP
|
114
|
+
CHANGES_DATES = ['Date']
|
115
|
+
|
116
|
+
CLIENTS_MAP = {
|
117
|
+
'client' => 'Client'
|
118
|
+
}
|
119
|
+
CLIENTS_DATES = %w(Update Access)
|
120
|
+
|
121
|
+
DEPOTS_MAP = {
|
122
|
+
'name' => 'Depot',
|
123
|
+
'time' => 'Date',
|
124
|
+
'type' => 'Type',
|
125
|
+
'map' => 'Map',
|
126
|
+
'desc' => 'Description'
|
127
|
+
}
|
128
|
+
DEPOTS_DATES = ['Date']
|
129
|
+
|
130
|
+
DIRS_MAP = {
|
131
|
+
'dir' => 'Dir'
|
132
|
+
}
|
133
|
+
|
134
|
+
FILES_MAP = {
|
135
|
+
'depotFile' => 'DepotFile',
|
136
|
+
'rev' => 'Revision',
|
137
|
+
'change' => 'Change',
|
138
|
+
'action' => 'Action',
|
139
|
+
'type' => 'Type',
|
140
|
+
'time' => 'Date'
|
141
|
+
}
|
142
|
+
FILES_DATES = %w(Date)
|
143
|
+
|
144
|
+
GROUPS_MAP = {
|
145
|
+
'group' => 'Group',
|
146
|
+
'maxResults' => 'MaxResults',
|
147
|
+
'maxScanRows' => 'MaxScanRows',
|
148
|
+
'maxLockTime' => 'MaxLockTime',
|
149
|
+
'timeout' => 'Timeout',
|
150
|
+
'desc' => 'Description',
|
151
|
+
'user' => 'User',
|
152
|
+
'isSubGroup' => 'IsSubGroup',
|
153
|
+
'isOwner' => 'IsOwner',
|
154
|
+
'isUser' => 'IsUser',
|
155
|
+
'passTimeout' => 'PasswordTimeout'
|
156
|
+
}
|
157
|
+
|
158
|
+
JOBS_MAP = {}
|
159
|
+
JOBS_DATES = %w(Date)
|
160
|
+
|
161
|
+
LABELS_MAP = {
|
162
|
+
'label' => 'Label'
|
163
|
+
}
|
164
|
+
LABELS_DATES = %w(Update Access)
|
165
|
+
|
166
|
+
# This isn't supported but kept here for reference. We *might* support this.
|
167
|
+
# OPENED_MAP = {
|
168
|
+
# 'change' => 'Change',
|
169
|
+
# 'client' => 'Client',
|
170
|
+
# 'user' => 'User'
|
171
|
+
# }
|
172
|
+
|
173
|
+
STREAMS_MAP = {
|
174
|
+
'desc' => 'Description'
|
175
|
+
}
|
176
|
+
STREAMS_DATES = %w(Update Access)
|
177
|
+
|
178
|
+
USERS_MAP = {
|
179
|
+
'passwordChange' => 'PasswordChange'
|
180
|
+
}
|
181
|
+
USERS_DATES = %w(Update Access PasswordChange)
|
182
|
+
|
183
|
+
# For each 'spec type' returns a function that will ensure that:
|
184
|
+
# - case is consistent between different return calls, prefer 'spec' types
|
185
|
+
# - dates are returned
|
186
|
+
#
|
187
|
+
# Parameters:
|
188
|
+
# - spec_type is the 'plural' form of spec class, e.g., 'users', 'clients'
|
189
|
+
# - offset is the current server offset, retrieve this via 'p4 info' command
|
190
|
+
#
|
191
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
192
|
+
def self.normalizer(spec_type, offset)
|
193
|
+
case spec_type
|
194
|
+
when 'branches'
|
195
|
+
make_normalizer(BRANCHES_MAP, offset, BRANCHES_DATES)
|
196
|
+
when 'changes'
|
197
|
+
make_normalizer(CHANGES_MAP, offset, CHANGES_DATES)
|
198
|
+
when 'clients'
|
199
|
+
make_normalizer(CLIENTS_MAP, offset, CLIENTS_DATES)
|
200
|
+
when 'depots'
|
201
|
+
make_normalizer(DEPOTS_MAP, offset, DEPOTS_DATES)
|
202
|
+
when 'dirs'
|
203
|
+
make_normalizer(DIRS_MAP, offset)
|
204
|
+
when 'files'
|
205
|
+
make_normalizer(FILES_MAP, offset, FILES_DATES)
|
206
|
+
when 'groups'
|
207
|
+
date_and_case = make_normalizer(GROUPS_MAP, offset)
|
208
|
+
lambda do |results|
|
209
|
+
results = date_and_case.call(results)
|
210
|
+
P4Util.replace_unset_with_0(results)
|
211
|
+
results
|
212
|
+
end
|
213
|
+
when 'jobs'
|
214
|
+
make_normalizer(JOBS_MAP, offset, JOBS_DATES)
|
215
|
+
when 'labels'
|
216
|
+
make_normalizer(LABELS_MAP, offset, LABELS_DATES)
|
217
|
+
when 'streams'
|
218
|
+
make_normalizer(STREAMS_MAP, offset, STREAMS_DATES)
|
219
|
+
when 'users'
|
220
|
+
make_normalizer(USERS_MAP, offset, USERS_DATES)
|
221
|
+
else
|
222
|
+
# By default, do no translation
|
223
|
+
return ->(x) { x }
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def self.make_normalizer(field_map, offset, date_fields = nil)
|
228
|
+
lambda do |results|
|
229
|
+
return unless results
|
230
|
+
|
231
|
+
# We need to ignore any instances of P4::Spec since that will 'validate'
|
232
|
+
# fields on it's accessor methods
|
233
|
+
results.map! do |result|
|
234
|
+
if result.class <= P4::Spec
|
235
|
+
spec = result
|
236
|
+
result = Hash.new
|
237
|
+
result.merge!(spec)
|
238
|
+
else
|
239
|
+
result
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
results.each do |result|
|
244
|
+
update_fields(field_map, result)
|
245
|
+
update_dates(date_fields, result, offset)
|
246
|
+
end
|
247
|
+
results
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
# These are 'single values' of properties of Group objects we use while
|
252
|
+
# collating them.
|
253
|
+
GROUP_PROPERTIES = %w(
|
254
|
+
Group MaxResults MaxScanRows MaxLockTime Timeout PasswordTimeout
|
255
|
+
)
|
256
|
+
|
257
|
+
# Will apply the normalizer to set up a consistent field naming and date
|
258
|
+
# format, but then 'collates' the output into a single list of 'groups'.
|
259
|
+
#
|
260
|
+
# The groups output is kind of funny, because it basically lists users,
|
261
|
+
# with group access fields. It's very non-obvious, and in the end, most
|
262
|
+
# clients will need to run logic like this anyway.
|
263
|
+
def self.collate_group_results(results)
|
264
|
+
collated = results.group_by { |x| x['Group'] }
|
265
|
+
updated = collated.map do |_key, items|
|
266
|
+
# The first item sets most of the values, we then figure out array
|
267
|
+
# subvalues
|
268
|
+
|
269
|
+
group = {}
|
270
|
+
GROUP_PROPERTIES.each do |p|
|
271
|
+
group[p] = items.first[p] if items.first.key?(p)
|
272
|
+
end
|
273
|
+
|
274
|
+
group['Users'] =
|
275
|
+
items.find_all { |x| x['IsUser'] == '1' }.map { |x| x['User'] }
|
276
|
+
|
277
|
+
group['Subgroups'] =
|
278
|
+
items.find_all { |x| x['IsSubGroup'] == '1' }.map { |x| x['User'] }
|
279
|
+
|
280
|
+
group['Owners'] =
|
281
|
+
items.find_all { |x| x['IsOwner'] == '1' }.map { |x| x['User'] }
|
282
|
+
|
283
|
+
group
|
284
|
+
end
|
285
|
+
|
286
|
+
results.replace(updated)
|
287
|
+
end
|
288
|
+
|
289
|
+
def self.replace_unset_with_0(results)
|
290
|
+
results.each do |r|
|
291
|
+
r.each_key { |k| r[k] = '0' if r[k] == 'unset' }
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
# Returns true if .to_i will actually convert this string to an integer
|
296
|
+
def self.i?(str)
|
297
|
+
(str =~ /\A[-+]?\d+\z/)
|
298
|
+
end
|
299
|
+
|
300
|
+
# Returns true if we can parse the string as a date
|
301
|
+
def self.date_str?(str)
|
302
|
+
Date.parse(str) rescue false
|
303
|
+
end
|
304
|
+
|
305
|
+
# In general we get dates without any offset information, which has to be
|
306
|
+
# retrieved via the serverDate field from 'p4 info'
|
307
|
+
def self.p4_date_to_i(offset, p4date)
|
308
|
+
DateTime.parse("#{p4date} #{offset}").to_time.to_i
|
309
|
+
end
|
310
|
+
|
311
|
+
def self.p4_date_offset(str)
|
312
|
+
DateTime.parse(str).zone
|
313
|
+
end
|
314
|
+
|
315
|
+
def self.resolve_host(env, settings)
|
316
|
+
if env.key?('P4_HOST') && settings.allow_env_p4_config
|
317
|
+
env['P4_HOST']
|
318
|
+
else
|
319
|
+
settings.p4['host']
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
def self.resolve_port(env, settings)
|
324
|
+
if env.key?('P4_PORT') && settings.allow_env_p4_config
|
325
|
+
env['P4_PORT']
|
326
|
+
else
|
327
|
+
settings.p4['port']
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
def self.resolve_charset(env, settings)
|
332
|
+
if env.key?('P4_CHARSET') && settings.allow_env_p4_config
|
333
|
+
env['P4_CHARSET']
|
334
|
+
else
|
335
|
+
settings.p4['charset'] if settings.p4.key?('charset')
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
def self.resolve_password(env, settings)
|
340
|
+
password = env['AUTH_CREDENTIALS'].last
|
341
|
+
if !P4Util.uuid?(password)
|
342
|
+
password
|
343
|
+
else
|
344
|
+
P4WebAPI::Auth.read_token(password, settings)['ticket']
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
private
|
349
|
+
|
350
|
+
def self.create_p4(options)
|
351
|
+
p4 = P4.new
|
352
|
+
|
353
|
+
init_p4(p4)
|
354
|
+
|
355
|
+
PROPERTIES.each do|key|
|
356
|
+
p4.method(key.to_s + '=').call(options[key]) if options[key]
|
357
|
+
end
|
358
|
+
|
359
|
+
# From the WTF department, if you think you don't want a charset set,
|
360
|
+
# make damn sure P4Ruby isn't going to get confused.
|
361
|
+
p4.charset = nil if p4.charset == 'none'
|
362
|
+
|
363
|
+
# Make P4Ruby only raise exceptions if there are errors. Warnings
|
364
|
+
# (such as 'no such file(s)' don't get the same treatment.
|
365
|
+
p4.exception_level = P4::RAISE_ERRORS
|
366
|
+
|
367
|
+
p4
|
368
|
+
end
|
369
|
+
|
370
|
+
# Before we do anything, clear out any environment variables that may have
|
371
|
+
# leaked in from your environment
|
372
|
+
#
|
373
|
+
# Some of our HTTP APIs (e.g, Git Fusion) *do* utilize environment setups
|
374
|
+
# for a system user, that may be installed alongside this API. We want this
|
375
|
+
# system to always explicitly state which configuration to use. (So, it's
|
376
|
+
# not just a concern of our development environments.)
|
377
|
+
def self.init_p4(p4)
|
378
|
+
p4.client = 'invalid'
|
379
|
+
p4.port = ''
|
380
|
+
p4.host = ''
|
381
|
+
p4.password = ''
|
382
|
+
end
|
383
|
+
|
384
|
+
def self.make_p4_error(p4)
|
385
|
+
if p4.messages && p4.messages.first
|
386
|
+
m = p4.messages.first
|
387
|
+
P4WebAPI::P4Error.new(m.msgid, m.severity, m.to_s)
|
388
|
+
else
|
389
|
+
P4WebAPI::P4Error.default_error($ERROR_INFO.to_s)
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
def self.update_fields(field_map, result)
|
394
|
+
field_map.each_key do |key|
|
395
|
+
next unless result.key?(key)
|
396
|
+
val = result[key]
|
397
|
+
result.delete(key)
|
398
|
+
result[field_map[key]] = val
|
399
|
+
end
|
400
|
+
end
|
401
|
+
|
402
|
+
# Ensures that all dates looks like epoch second (integer) times
|
403
|
+
def self.update_dates(date_fields, result, offset)
|
404
|
+
if date_fields
|
405
|
+
date_fields.each do |date_key|
|
406
|
+
next unless result.key?(date_key)
|
407
|
+
val = result[date_key]
|
408
|
+
if i?(val)
|
409
|
+
result[date_key] = val.to_i
|
410
|
+
elsif date_str?(val)
|
411
|
+
result[date_key] = p4_date_to_i(offset, val)
|
412
|
+
end
|
413
|
+
end
|
414
|
+
end
|
415
|
+
end
|
416
|
+
end
|
417
|
+
end
|