xolo-server 1.0.0
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/LICENSE.txt +177 -0
- data/README.md +7 -0
- data/bin/xoloserver +106 -0
- data/data/com.pixar.xoloserver.plist +29 -0
- data/data/uninstall-pkgs-by-id.zsh +103 -0
- data/lib/xolo/server/app.rb +133 -0
- data/lib/xolo/server/command_line.rb +216 -0
- data/lib/xolo/server/configuration.rb +739 -0
- data/lib/xolo/server/constants.rb +70 -0
- data/lib/xolo/server/helpers/auth.rb +257 -0
- data/lib/xolo/server/helpers/client_data.rb +415 -0
- data/lib/xolo/server/helpers/file_transfers.rb +265 -0
- data/lib/xolo/server/helpers/jamf_pro.rb +156 -0
- data/lib/xolo/server/helpers/log.rb +97 -0
- data/lib/xolo/server/helpers/maintenance.rb +401 -0
- data/lib/xolo/server/helpers/notification.rb +145 -0
- data/lib/xolo/server/helpers/pkg_signing.rb +141 -0
- data/lib/xolo/server/helpers/progress_streaming.rb +252 -0
- data/lib/xolo/server/helpers/title_editor.rb +92 -0
- data/lib/xolo/server/helpers/titles.rb +145 -0
- data/lib/xolo/server/helpers/versions.rb +160 -0
- data/lib/xolo/server/log.rb +286 -0
- data/lib/xolo/server/mixins/changelog.rb +315 -0
- data/lib/xolo/server/mixins/title_jamf_access.rb +1668 -0
- data/lib/xolo/server/mixins/title_ted_access.rb +519 -0
- data/lib/xolo/server/mixins/version_jamf_access.rb +1541 -0
- data/lib/xolo/server/mixins/version_ted_access.rb +373 -0
- data/lib/xolo/server/object_locks.rb +102 -0
- data/lib/xolo/server/routes/auth.rb +89 -0
- data/lib/xolo/server/routes/jamf_pro.rb +89 -0
- data/lib/xolo/server/routes/maint.rb +174 -0
- data/lib/xolo/server/routes/title_editor.rb +71 -0
- data/lib/xolo/server/routes/titles.rb +285 -0
- data/lib/xolo/server/routes/uploads.rb +93 -0
- data/lib/xolo/server/routes/versions.rb +261 -0
- data/lib/xolo/server/routes.rb +168 -0
- data/lib/xolo/server/title.rb +1143 -0
- data/lib/xolo/server/version.rb +902 -0
- data/lib/xolo/server.rb +205 -0
- data/lib/xolo-server.rb +8 -0
- metadata +243 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
# Copyright 2025 Pixar
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the terms set forth in the LICENSE.txt file available at
|
|
4
|
+
# at the root of this project.
|
|
5
|
+
#
|
|
6
|
+
#
|
|
7
|
+
|
|
8
|
+
# frozen_string_literal: true
|
|
9
|
+
|
|
10
|
+
# main module
|
|
11
|
+
module Xolo
|
|
12
|
+
|
|
13
|
+
module Server
|
|
14
|
+
|
|
15
|
+
# Xolo Version/Patch as used on the Xolo Server
|
|
16
|
+
#
|
|
17
|
+
# The code in this file mostly deals with the data on the Xolo server itself, and
|
|
18
|
+
# general methods for manipulating the version.
|
|
19
|
+
#
|
|
20
|
+
# Code for interacting with the Title Editor and Jamf Pro are in the helpers and mixins.
|
|
21
|
+
#
|
|
22
|
+
# NOTE be sure to only instantiate these using the
|
|
23
|
+
# server's 'instantiate_version' method, or else
|
|
24
|
+
# they might not have all the correct innards
|
|
25
|
+
###
|
|
26
|
+
class Version < Xolo::Core::BaseClasses::Version
|
|
27
|
+
|
|
28
|
+
# Mixins
|
|
29
|
+
#############################
|
|
30
|
+
#############################
|
|
31
|
+
include Comparable
|
|
32
|
+
|
|
33
|
+
include Xolo::Server::Helpers::JamfPro
|
|
34
|
+
include Xolo::Server::Helpers::TitleEditor
|
|
35
|
+
include Xolo::Server::Helpers::Log
|
|
36
|
+
include Xolo::Server::Helpers::Notification
|
|
37
|
+
|
|
38
|
+
include Xolo::Server::Mixins::Changelog
|
|
39
|
+
include Xolo::Server::Mixins::VersionJamfAccess
|
|
40
|
+
include Xolo::Server::Mixins::VersionTedAccess
|
|
41
|
+
|
|
42
|
+
# Constants
|
|
43
|
+
######################
|
|
44
|
+
######################
|
|
45
|
+
|
|
46
|
+
# On the server, xolo versions are represented by JSON files
|
|
47
|
+
# in the 'versions' directory of the title directory
|
|
48
|
+
#
|
|
49
|
+
# So a title 'foobar' would have a directory
|
|
50
|
+
# (Xolo::Server::DATA_DIR)/titles/foobar/
|
|
51
|
+
#
|
|
52
|
+
# In there will be a 'versions' dir containing json
|
|
53
|
+
# files for each version of the title.
|
|
54
|
+
#
|
|
55
|
+
VERSIONS_DIRNAME = 'versions'
|
|
56
|
+
|
|
57
|
+
JAMF_PKG_NOTES_VERS_PH = 'XOLO-VERSION-HERE'
|
|
58
|
+
JAMF_PKG_NOTES_TITLE_PH = 'XOLO-TITLE-HERE'
|
|
59
|
+
|
|
60
|
+
# The 'Notes' of a jamf pkg are the Xolo Title Description, with this prepended
|
|
61
|
+
JAMF_PKG_NOTES_PREFIX = <<~ENDNOTES
|
|
62
|
+
This package is maintained by 'xolo', to install version '#{JAMF_PKG_NOTES_VERS_PH}' of title '#{JAMF_PKG_NOTES_TITLE_PH}'. The description in Xolo is:
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
ENDNOTES
|
|
66
|
+
|
|
67
|
+
MAX_PKG_DELETION_THREADS = 10
|
|
68
|
+
|
|
69
|
+
# STUB PATCH
|
|
70
|
+
#
|
|
71
|
+
# We create a fake 'stub' patch with all ted titles
|
|
72
|
+
# so that we can activate the title before any real version is added
|
|
73
|
+
# and also accept any EA/version_script, either manually or automatically
|
|
74
|
+
#
|
|
75
|
+
# This version should never be available to any mac, and needs no patch
|
|
76
|
+
# policies or packages.
|
|
77
|
+
#
|
|
78
|
+
# It should also never be deleted until the title itself is deleted.
|
|
79
|
+
|
|
80
|
+
STUB_PATCH_VERSION = '0.0.0x0'
|
|
81
|
+
|
|
82
|
+
# machines that can install this version
|
|
83
|
+
STUB_PATCH_CAPABILITY_CRITERION_NAME = 'Operating System Version'
|
|
84
|
+
STUB_PATCH_CAPABILITY_CRITERION_OPERATOR = 'less than or equal'
|
|
85
|
+
STUB_PATCH_CAPABILITY_CRITERION_VALUE = '10.0'
|
|
86
|
+
|
|
87
|
+
# machines that have this version installed
|
|
88
|
+
STUB_PATCH_COMPONENT_NAME = 'Xolo Stub'
|
|
89
|
+
STUB_PATCH_COMPONENT_CRITERION_NAME = 'Application Title'
|
|
90
|
+
STUB_PATCH_COMPONENT_CRITERION_OPERATOR = 'is'
|
|
91
|
+
STUB_PATCH_COMPONENT_CRITERION_VALUE = 'XoloStub-DoesNotExist.app'
|
|
92
|
+
|
|
93
|
+
# Class Methods
|
|
94
|
+
######################
|
|
95
|
+
######################
|
|
96
|
+
|
|
97
|
+
# @pararm title [String] the title for the version
|
|
98
|
+
# @return [Pathname] The directory containing subdirectories for each version of a title.
|
|
99
|
+
# They contain JSON and other files for the versions.
|
|
100
|
+
######################
|
|
101
|
+
def self.version_dir(title)
|
|
102
|
+
Xolo::Server::Title.title_dir(title) + VERSIONS_DIRNAME
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @pararm title [String] the title for the versions
|
|
106
|
+
# @return [Array<Pathname>] All version directories for a title
|
|
107
|
+
######################
|
|
108
|
+
def self.version_dirs(title)
|
|
109
|
+
vdir = version_dir(title)
|
|
110
|
+
vdir.directory? ? vdir.children : []
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @pararm title [String] the title for the version
|
|
114
|
+
# @return [Array<String>] A list of all known versions for a title,
|
|
115
|
+
# just the basenames of all the version files with the extension removed
|
|
116
|
+
######################
|
|
117
|
+
def self.all_versions(title)
|
|
118
|
+
version_dirs(title).map { |c| c.basename.to_s }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The the local directory containing various files
|
|
122
|
+
# specific to the given version of a title
|
|
123
|
+
#
|
|
124
|
+
# @pararm title [String] the title for the version
|
|
125
|
+
#
|
|
126
|
+
# @pararm version [String] the version we care about
|
|
127
|
+
#
|
|
128
|
+
# @return [Pathname]
|
|
129
|
+
#####################
|
|
130
|
+
def self.data_dir(title, version)
|
|
131
|
+
version_dir(title) + version
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The the local JSON file containing the current values
|
|
135
|
+
# for the given version of a title
|
|
136
|
+
#
|
|
137
|
+
# @pararm title [String] the title for the version
|
|
138
|
+
#
|
|
139
|
+
# @pararm version [String] the version we care about
|
|
140
|
+
#
|
|
141
|
+
# @return [Pathname]
|
|
142
|
+
#####################
|
|
143
|
+
def self.data_file(title, version)
|
|
144
|
+
data_dir(title, version) + "#{version}.json"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The the local xml plist file containing the
|
|
148
|
+
# .pkg manifest for the given version of a title
|
|
149
|
+
#
|
|
150
|
+
# @pararm title [String] the title for the version
|
|
151
|
+
#
|
|
152
|
+
# @pararm version [String] the version we care about
|
|
153
|
+
#
|
|
154
|
+
# @return [Pathname]
|
|
155
|
+
#####################
|
|
156
|
+
def self.manifest_file(title, version)
|
|
157
|
+
data_dir(title, version) + "#{version}.manifest.plist"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Instantiate from the local JSON file containing the current values
|
|
161
|
+
# for the given version of a title
|
|
162
|
+
#
|
|
163
|
+
# NOTE: All instantiation should happen using the #instantiate_version method
|
|
164
|
+
# in the server app instance. Please don't call this method directly
|
|
165
|
+
#
|
|
166
|
+
# @pararm title [String] the title for the version
|
|
167
|
+
#
|
|
168
|
+
# @pararm version [String] the version we care about
|
|
169
|
+
#
|
|
170
|
+
# @return [Xolo::Server::Title] load an existing title
|
|
171
|
+
# from the on-disk JSON file
|
|
172
|
+
######################
|
|
173
|
+
def self.load(title, version)
|
|
174
|
+
Xolo::Server.logger.debug "Loading version '#{version}' of title '#{title}' from file"
|
|
175
|
+
new parse_json(data_file(title, version).read)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @param patch_id [String] the id number of the patch we are looking for
|
|
179
|
+
# @pararm cnx [Windoo::Connection] The Title Editor connection to use
|
|
180
|
+
# @return [Boolean] Does the given patch exist in the Title Editor?
|
|
181
|
+
###############################
|
|
182
|
+
def self.in_ted?(patch_id, cnx:)
|
|
183
|
+
Windoo::Patch.all_ids(cnx: cnx).include? patch_id
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Is a version locked for updates?
|
|
187
|
+
#############################
|
|
188
|
+
def self.locked?(title, version)
|
|
189
|
+
curr_lock = Xolo::Server.object_locks.dig title, :versions, version
|
|
190
|
+
curr_lock && curr_lock > Time.now
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The package-deletion thread pool
|
|
194
|
+
#
|
|
195
|
+
# the auto_terminate is false to prevents the threads from being daemonized,
|
|
196
|
+
# and running after the main thread exits. This is important because launchd
|
|
197
|
+
# jobs should never do that.
|
|
198
|
+
#
|
|
199
|
+
# See https://ruby-concurrency.github.io/concurrent-ruby/master/file.thread_pools.html
|
|
200
|
+
# @return [Queue] The package-deletion thread pool
|
|
201
|
+
###############################
|
|
202
|
+
def self.pkg_deletion_pool
|
|
203
|
+
@pkg_deletion_pool ||= Concurrent::ThreadPoolExecutor.new(
|
|
204
|
+
name: 'package-deletion',
|
|
205
|
+
min_threads: 1, # start with 1 thread
|
|
206
|
+
max_threads: MAX_PKG_DELETION_THREADS, # create at most 10 threads
|
|
207
|
+
max_queue: 0, # no limit
|
|
208
|
+
auto_terminate: false, # see method comments above
|
|
209
|
+
idletime: 60 # seconds thread can remain idle before it is reclaimed, default is 60
|
|
210
|
+
# fallback_policy: :abort # the default is :abort, which will raise a
|
|
211
|
+
# Concurrent::RejectedExecutionError exception and discard the task
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# info about the current pkg deletion pool state, for
|
|
216
|
+
# the /state route
|
|
217
|
+
# @return [Hash]
|
|
218
|
+
###############################
|
|
219
|
+
def self.pkg_deletion_pool_info
|
|
220
|
+
{
|
|
221
|
+
threads: pkg_deletion_pool.length,
|
|
222
|
+
queued_tasks: pkg_deletion_pool.queue_length
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Attributes
|
|
227
|
+
######################
|
|
228
|
+
######################
|
|
229
|
+
|
|
230
|
+
# The instance of Xolo::Server::App that instantiated this
|
|
231
|
+
# title object. This is how we access things that are available in routes
|
|
232
|
+
# and helpers, like the single Jamf and TEd
|
|
233
|
+
# connections for this App instance.
|
|
234
|
+
attr_accessor :server_app_instance
|
|
235
|
+
|
|
236
|
+
# The sinatra session that instantiates this version
|
|
237
|
+
# attr_writer :session
|
|
238
|
+
|
|
239
|
+
# The Xolo::Server::Title that contains, and usually instantiated
|
|
240
|
+
# this version object
|
|
241
|
+
attr_writer :title_object
|
|
242
|
+
|
|
243
|
+
# The Windoo::Patch#patchId
|
|
244
|
+
attr_accessor :ted_id_number
|
|
245
|
+
|
|
246
|
+
# Jamf object names start with this
|
|
247
|
+
attr_reader :jamf_obj_name_pfx
|
|
248
|
+
|
|
249
|
+
# For each version there will be a smart group containing all macs
|
|
250
|
+
# that have that version of the title installed. The smart group
|
|
251
|
+
# will be named 'xolo-<title>-<version>-installed'
|
|
252
|
+
#
|
|
253
|
+
# It will be used as the target for the auto-reinstall
|
|
254
|
+
#
|
|
255
|
+
# @return [String] the name of the smart group
|
|
256
|
+
attr_reader :jamf_installed_group_name
|
|
257
|
+
|
|
258
|
+
# Jamf auto-install policies are named this
|
|
259
|
+
attr_reader :jamf_auto_install_policy_name
|
|
260
|
+
|
|
261
|
+
# Jamf manual install policies are named this
|
|
262
|
+
attr_reader :jamf_manual_install_policy_name
|
|
263
|
+
|
|
264
|
+
# Jamf auto re-install policies are named this
|
|
265
|
+
attr_reader :jamf_auto_reinstall_policy_name
|
|
266
|
+
|
|
267
|
+
# the custom trigger is the same
|
|
268
|
+
alias jamf_manual_install_trigger jamf_manual_install_policy_name
|
|
269
|
+
|
|
270
|
+
# Jamf Patch Policy is named this
|
|
271
|
+
attr_reader :jamf_patch_policy_name
|
|
272
|
+
|
|
273
|
+
# The Jamf Package object has this jamf id
|
|
274
|
+
attr_reader :jamf_pkg_id
|
|
275
|
+
|
|
276
|
+
# when applying updates, the new data is stored
|
|
277
|
+
# here so it can be accessed by update-methods
|
|
278
|
+
# and compared to the current instanace values
|
|
279
|
+
# both for updating the title, and the versions
|
|
280
|
+
attr_reader :new_data_for_update
|
|
281
|
+
|
|
282
|
+
# Also when applying updates, this will hold the
|
|
283
|
+
# changes being made: the differences between
|
|
284
|
+
# tne current attributs and the new_data_for_update
|
|
285
|
+
# We'll figure this out at the start of the update
|
|
286
|
+
# and can use it later to
|
|
287
|
+
# 1) avoid doing things we don't need to
|
|
288
|
+
# 2) log the changes in the change log at the very end
|
|
289
|
+
#
|
|
290
|
+
# This is a Hash with keys of the attribute names that have changed
|
|
291
|
+
# the values are Hashes with keys of :old and :new
|
|
292
|
+
# e.g. { pilot_groups: { old: ['foo'], new: ['bar'] } }
|
|
293
|
+
# @return [Hash]
|
|
294
|
+
attr_reader :changes_for_update
|
|
295
|
+
|
|
296
|
+
# @return [Symbol] The current action being taken on this title
|
|
297
|
+
# one of :creating, :updating, :deleting
|
|
298
|
+
attr_accessor :current_action
|
|
299
|
+
|
|
300
|
+
# Constructor
|
|
301
|
+
######################
|
|
302
|
+
######################
|
|
303
|
+
|
|
304
|
+
# NOTE: be sure to only instantiate these using the
|
|
305
|
+
# servers 'instantiate_version' method, or else
|
|
306
|
+
# they might not have all the correct innards
|
|
307
|
+
def initialize(data_hash)
|
|
308
|
+
super
|
|
309
|
+
|
|
310
|
+
# These attrs aren't defined in the ATTRIBUTES
|
|
311
|
+
# and/or are not stored in the on-disk json file
|
|
312
|
+
|
|
313
|
+
@ted_id_number ||= data_hash[:ted_id_number]
|
|
314
|
+
@jamf_pkg_id ||= data_hash[:jamf_pkg_id]
|
|
315
|
+
|
|
316
|
+
# and these can be generated now
|
|
317
|
+
@jamf_obj_name_pfx = "#{Xolo::Server::JAMF_OBJECT_NAME_PFX}#{title}-#{version}"
|
|
318
|
+
|
|
319
|
+
@jamf_pkg_name ||= @jamf_obj_name_pfx
|
|
320
|
+
|
|
321
|
+
@jamf_installed_group_name = "#{jamf_obj_name_pfx}#{JAMF_SMART_GROUP_NAME_INSTALLED_SFX}"
|
|
322
|
+
|
|
323
|
+
@jamf_auto_install_policy_name = "#{jamf_obj_name_pfx}#{JAMF_POLICY_NAME_AUTO_INSTALL_SFX}"
|
|
324
|
+
@jamf_manual_install_policy_name = "#{jamf_obj_name_pfx}#{JAMF_POLICY_NAME_MANUAL_INSTALL_SFX}"
|
|
325
|
+
@jamf_auto_reinstall_policy_name = "#{jamf_obj_name_pfx}#{JAMF_POLICY_NAME_AUTO_REINSTALL_SFX}"
|
|
326
|
+
|
|
327
|
+
@jamf_patch_policy_name = @jamf_obj_name_pfx
|
|
328
|
+
|
|
329
|
+
# we set @jamf_pkg_file when a pkg is uploaded
|
|
330
|
+
# since we don't know until then if its a .pkg or .zip
|
|
331
|
+
# It will be stored in the local data and reloaded as needed
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Instance Methods
|
|
335
|
+
######################
|
|
336
|
+
######################
|
|
337
|
+
|
|
338
|
+
# version comparison
|
|
339
|
+
# @see Comparable
|
|
340
|
+
#########################
|
|
341
|
+
def <=>(other)
|
|
342
|
+
raise Xolo::InvalidDataError, 'Cannot compare with other classes' unless other.is_a? Xolo::Server::Version
|
|
343
|
+
raise Xolo::InvalidDataError, 'Cannot compare versions of different titles' unless other.title == title
|
|
344
|
+
|
|
345
|
+
order_index <=> other.order_index
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# @return [Integer] The index of this version in the title's reversed version_order array.
|
|
349
|
+
# We reverse it because the version_order array holds the newest versions first,
|
|
350
|
+
# so the index of the newest version is 0, the next newest is 1, etc - we need the opposite of that.
|
|
351
|
+
######################
|
|
352
|
+
def order_index
|
|
353
|
+
title_object.version_order.reverse.index version
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @return [Boolean] Are we creating this version?
|
|
357
|
+
###################
|
|
358
|
+
def creating?
|
|
359
|
+
current_action == :creating
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# @return [Boolean] Are we updating this version?
|
|
363
|
+
###################
|
|
364
|
+
def updating?
|
|
365
|
+
current_action == :updating
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# @return [Boolean] Are we repairing this version?
|
|
369
|
+
###################
|
|
370
|
+
def repairing?
|
|
371
|
+
current_action == :repairing
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# @return [Boolean] Are we deleting this version?
|
|
375
|
+
###################
|
|
376
|
+
def deleting?
|
|
377
|
+
current_action == :deleting
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# @return [Boolean] Are we releasing this version?
|
|
381
|
+
###################
|
|
382
|
+
def releasing?
|
|
383
|
+
current_action == :releasing
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The scope target groups to use in policies and patch policies during pilot
|
|
387
|
+
# This is defined in each version, and inherited when new versions are created.
|
|
388
|
+
#
|
|
389
|
+
# @return [Array<String>] the pilot groups to use
|
|
390
|
+
######################
|
|
391
|
+
def pilot_groups_to_use
|
|
392
|
+
return @pilot_groups_to_use if @pilot_groups_to_use
|
|
393
|
+
|
|
394
|
+
@pilot_groups_to_use = changes_for_update&.key?(:pilot_groups) ? changes_for_update[:pilot_groups][:new] : pilot_groups
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# The scope excluded groups to use in policies and patch policies for all versions of
|
|
398
|
+
# this title.
|
|
399
|
+
#
|
|
400
|
+
# Excluded groups are defined in the title, applying to all versions, and may be augmented by:
|
|
401
|
+
# - Xolo::Server.config.forced_exclusion, a group excluded from ALL of xolo, defined
|
|
402
|
+
# in the server config.
|
|
403
|
+
# - The title's jamf_frozen_group_name, if it exists, containing computers that have been
|
|
404
|
+
# 'frozen' to a single version.
|
|
405
|
+
#
|
|
406
|
+
# For initial install policies, the smart group of macs with any version installed
|
|
407
|
+
# (jamf_installed_group_name) "xolo-<title>-installed" is also excluded, because
|
|
408
|
+
# otherwise the initial-install policies would stomp on the patch policies.
|
|
409
|
+
#
|
|
410
|
+
# @param ttl_obj [Xolo::Server::Title] The pre-instantiated title for ths version.
|
|
411
|
+
# if nil, we'll instantiate it now
|
|
412
|
+
#
|
|
413
|
+
# @return [Array<String>] the excluded groups to use
|
|
414
|
+
######################
|
|
415
|
+
def excluded_groups_to_use(ttl_obj: nil)
|
|
416
|
+
return @excluded_groups_to_use if @excluded_groups_to_use
|
|
417
|
+
|
|
418
|
+
ttl_obj ||= title_object
|
|
419
|
+
# get the excluded groups from the title
|
|
420
|
+
# Use .dup so we don't modify the original
|
|
421
|
+
@excluded_groups_to_use = ttl_obj.changes_for_update&.key?(:excluded_groups) ? ttl_obj.changes_for_update[:excluded_groups][:new].dup : ttl_obj.excluded_groups.dup
|
|
422
|
+
|
|
423
|
+
# always exclude the frozen static group
|
|
424
|
+
# calling ttl_obj.jamf_frozen_group will create the group if needed
|
|
425
|
+
@excluded_groups_to_use << ttl_obj.jamf_frozen_group.name
|
|
426
|
+
log_debug "Appended '#{ttl_obj.jamf_frozen_group_name}' to @excluded_groups_to_use"
|
|
427
|
+
|
|
428
|
+
# always exclude Xolo::Server.config.forced_exclusion if defined
|
|
429
|
+
@excluded_groups_to_use << valid_forced_exclusion_group_name if valid_forced_exclusion_group_name
|
|
430
|
+
|
|
431
|
+
@excluded_groups_to_use.uniq!
|
|
432
|
+
log_debug "Excluded groups to use: #{@excluded_groups_to_use.join ', '}"
|
|
433
|
+
|
|
434
|
+
@excluded_groups_to_use
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# The scope target groups to use in policies and patch policies when the version is released
|
|
438
|
+
# This is defined in the title and applies to all versions.
|
|
439
|
+
#
|
|
440
|
+
# @param ttl_obj [Xolo::Server::Title] The pre-instantiated title for ths version.
|
|
441
|
+
# if nil, we'll instantiate it now
|
|
442
|
+
#
|
|
443
|
+
# @return [Array<String>] the target groups to use
|
|
444
|
+
######################
|
|
445
|
+
def release_groups_to_use(ttl_obj: nil)
|
|
446
|
+
return @release_groups_to_use if @release_groups_to_use
|
|
447
|
+
|
|
448
|
+
ttl_obj ||= title_object
|
|
449
|
+
@release_groups_to_use = ttl_obj.changes_for_update&.key?(:release_groups) ? ttl_obj.changes_for_update[:release_groups][:new] : ttl_obj.release_groups
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# @return [Hash]
|
|
453
|
+
###################
|
|
454
|
+
def session
|
|
455
|
+
server_app_instance&.session || {}
|
|
456
|
+
# @session ||= {}
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# @return [String]
|
|
460
|
+
###################
|
|
461
|
+
def admin
|
|
462
|
+
session[:admin]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Append a message to the progress stream file,
|
|
466
|
+
# optionally sending it also to the log
|
|
467
|
+
#
|
|
468
|
+
# @param message [String] the message to append
|
|
469
|
+
# @param log [Symbol] the level at which to log the message
|
|
470
|
+
# one of :debug, :info, :warn, :error, :fatal, or :unknown.
|
|
471
|
+
# Default is nil, which doesn't log the message at all.
|
|
472
|
+
#
|
|
473
|
+
# @return [void]
|
|
474
|
+
###################
|
|
475
|
+
def progress(msg, log: :debug)
|
|
476
|
+
server_app_instance.progress msg, log: log
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# This might have been set already if we were instantiated via our title
|
|
480
|
+
# @return [Xolo::Server::Title] the title for this version
|
|
481
|
+
################
|
|
482
|
+
def title_object(refresh: false)
|
|
483
|
+
@title_object = nil if refresh
|
|
484
|
+
@title_object ||= server_app_instance.instantiate_title title
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# @return [Windoo::Connection] a single Title Editor connection to use for
|
|
488
|
+
# the life of this instance
|
|
489
|
+
#############################
|
|
490
|
+
def ted_cnx
|
|
491
|
+
server_app_instance.ted_cnx
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# @return [Jamf::Connection] a single Jamf Pro API connection to use for
|
|
495
|
+
# the life of this instance
|
|
496
|
+
#############################
|
|
497
|
+
def jamf_cnx(refresh: false)
|
|
498
|
+
server_app_instance.jamf_cnx refresh: refresh
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# The data directory for this version
|
|
502
|
+
# @return [Pathname]
|
|
503
|
+
#########################
|
|
504
|
+
def data_dir
|
|
505
|
+
self.class.data_dir title, version
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# The JSON data file for this version
|
|
509
|
+
# @return [Pathname]
|
|
510
|
+
#########################
|
|
511
|
+
def data_file
|
|
512
|
+
self.class.data_file title, version
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# The manifest plist file for this version
|
|
516
|
+
# @return [Pathname]
|
|
517
|
+
#########################
|
|
518
|
+
def manifest_file
|
|
519
|
+
self.class.manifest_file title, version
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# TODO: maybe pass in an appropriate Windoo::SoftwareTitle, so
|
|
523
|
+
# we don't have to use refresh all the time to re-fetch, if we just
|
|
524
|
+
# re-fetched from elsewhere?
|
|
525
|
+
#
|
|
526
|
+
# @return [Windoo::Patch] The Windoo::Patch object that represents
|
|
527
|
+
# this version in the title editor
|
|
528
|
+
#############################
|
|
529
|
+
def ted_patch(refresh: false)
|
|
530
|
+
@ted_patch = nil if refresh
|
|
531
|
+
@ted_patch ||= ted_title(refresh: refresh).patches.patch(version)
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# @return [Windoo::SoftwareTitle] The Windoo::SoftwareTitle object that represents
|
|
535
|
+
# this version's title in the title editor
|
|
536
|
+
#############################
|
|
537
|
+
def ted_title(refresh: false)
|
|
538
|
+
@ted_title = nil if refresh
|
|
539
|
+
@ted_title ||= Windoo::SoftwareTitle.fetch id: title, cnx: ted_cnx
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Save a new version, adding to the
|
|
543
|
+
# local filesystem, Jamf Pro, and the Title Editor as needed
|
|
544
|
+
# This should be running in the context of #with_streaming
|
|
545
|
+
#
|
|
546
|
+
# @return [void]
|
|
547
|
+
#########################
|
|
548
|
+
def create
|
|
549
|
+
lock
|
|
550
|
+
@current_action = :creating
|
|
551
|
+
|
|
552
|
+
self.creation_date = Time.now
|
|
553
|
+
self.created_by = admin
|
|
554
|
+
self.status = STATUS_PENDING
|
|
555
|
+
log_debug "creation_date: #{creation_date}, created_by: #{created_by}"
|
|
556
|
+
|
|
557
|
+
# save to file here so that we have something to delete if
|
|
558
|
+
# the next couple steps fail
|
|
559
|
+
progress 'Saving version data to Xolo server'
|
|
560
|
+
save_local_data
|
|
561
|
+
|
|
562
|
+
create_patch_in_ted
|
|
563
|
+
enable_ted_patch
|
|
564
|
+
title_object.enable_ted_title
|
|
565
|
+
|
|
566
|
+
create_in_jamf
|
|
567
|
+
|
|
568
|
+
self.status = STATUS_PILOT
|
|
569
|
+
|
|
570
|
+
# save to file again now, because saving to TitleEd and Jamf will
|
|
571
|
+
# add some data
|
|
572
|
+
save_local_data
|
|
573
|
+
|
|
574
|
+
# prepend our version to the version_order array of the title
|
|
575
|
+
progress "Updating title version_order, prepending '#{version}'", log: :info
|
|
576
|
+
title_object.prepend_version(version)
|
|
577
|
+
|
|
578
|
+
log_change msg: 'Version Created'
|
|
579
|
+
|
|
580
|
+
progress "Version '#{version}' of Title '#{title}' has been created in Xolo.", log: :info
|
|
581
|
+
ensure
|
|
582
|
+
unlock
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Update a this version, updating to the
|
|
586
|
+
# local filesystem, Jamf Pro, and the Title Editor as needed
|
|
587
|
+
#
|
|
588
|
+
# @param new_data [Hash] The new data sent from xadm
|
|
589
|
+
# @return [void]
|
|
590
|
+
#########################
|
|
591
|
+
def update(new_data)
|
|
592
|
+
lock
|
|
593
|
+
@current_action = :updating
|
|
594
|
+
@new_data_for_update = new_data
|
|
595
|
+
@changes_for_update = note_changes_for_update_and_log
|
|
596
|
+
if @changes_for_update.pix_empty?
|
|
597
|
+
progress 'No changes to make', log: :info
|
|
598
|
+
return
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
log_info "Updating version '#{version}' of title '#{title}' for admin '#{admin}'"
|
|
602
|
+
|
|
603
|
+
# changelog - log the changes now, and
|
|
604
|
+
# if there is an error, we'll log that too
|
|
605
|
+
# saying the above changes were not completed and to
|
|
606
|
+
# look at the server log for details.
|
|
607
|
+
log_update_changes
|
|
608
|
+
|
|
609
|
+
# update ted before jamf
|
|
610
|
+
update_patch_in_ted
|
|
611
|
+
enable_ted_patch
|
|
612
|
+
update_version_in_jamf
|
|
613
|
+
update_local_instance_values
|
|
614
|
+
save_local_data
|
|
615
|
+
|
|
616
|
+
# new pkg uploads happen in a separate process
|
|
617
|
+
rescue => e
|
|
618
|
+
log_change msg: "ERROR: The update failed and the changes didn't all go through!\n#{e.class}: #{e.message}\nSee server log for details."
|
|
619
|
+
|
|
620
|
+
# re-raise for proper error handling in the server app
|
|
621
|
+
raise
|
|
622
|
+
ensure
|
|
623
|
+
unlock
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Repair this version.
|
|
627
|
+
# Look at the Title Editor patch object, and ensure it's correct based on the local data file.
|
|
628
|
+
# - version order
|
|
629
|
+
# - min os
|
|
630
|
+
# - max os
|
|
631
|
+
# - standalone
|
|
632
|
+
# - reboot
|
|
633
|
+
# - release date
|
|
634
|
+
# - killapps
|
|
635
|
+
# - component criteria
|
|
636
|
+
# - component name '<title>'
|
|
637
|
+
# - capability criteria
|
|
638
|
+
# - enabled
|
|
639
|
+
#
|
|
640
|
+
# Then look at the various Jamf objects pertaining to this version, and ensure they are correct
|
|
641
|
+
# - package object 'xolo-<title>-<version>'
|
|
642
|
+
# - filename 'xolo-<title>-<version>.pkg'
|
|
643
|
+
# - description
|
|
644
|
+
# - os limitations
|
|
645
|
+
# - auto install policy 'xolo-<title>-<version>-auto-install'
|
|
646
|
+
# - manual install policy 'xolo-<title>-<version>-manual-install'
|
|
647
|
+
# - patch policy 'xolo-<title>-<version>'
|
|
648
|
+
#
|
|
649
|
+
# Then look at the xolo metadata, and fix whatever is needed
|
|
650
|
+
##################################
|
|
651
|
+
def repair
|
|
652
|
+
lock
|
|
653
|
+
@current_action = :repairing
|
|
654
|
+
log_change msg: "Repairing version '#{version}'"
|
|
655
|
+
progress "Starting repair of version '#{version}' of title '#{title}'", log: :debug
|
|
656
|
+
|
|
657
|
+
repair_ted_patch
|
|
658
|
+
repair_jamf_version_objects
|
|
659
|
+
|
|
660
|
+
# If there's a reupload, but no original, make the orig the same as the re
|
|
661
|
+
unless upload_date
|
|
662
|
+
if reupload_date && reuploaded_by
|
|
663
|
+
new_date = reupload_date
|
|
664
|
+
new_by = reuploaded_by
|
|
665
|
+
else
|
|
666
|
+
new_date = Time.parse '2025-02-15'
|
|
667
|
+
new_by = 'buzzlightyear'
|
|
668
|
+
end
|
|
669
|
+
progress "Fixing original upload date: #{new_date}, by: #{new_by}", log: :debug
|
|
670
|
+
self.upload_date = new_date
|
|
671
|
+
self.uploaded_by = new_by
|
|
672
|
+
save_local_data
|
|
673
|
+
end
|
|
674
|
+
ensure
|
|
675
|
+
unlock
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Release this version, possibly rolling back from a previously newer version
|
|
679
|
+
#
|
|
680
|
+
# @param rollback [Boolean] If true, this version is being released as a rollback
|
|
681
|
+
#
|
|
682
|
+
# @return [void]
|
|
683
|
+
#########################
|
|
684
|
+
def release(rollback:)
|
|
685
|
+
lock
|
|
686
|
+
@current_action = :releasing
|
|
687
|
+
# set scope targets of auto-install policy to release-groups
|
|
688
|
+
msg = "Jamf: Version '#{version}': Setting scope targets of auto-install policy to release_groups: #{release_groups_to_use.join(', ')}"
|
|
689
|
+
progress msg, log: :info
|
|
690
|
+
pol = jamf_auto_install_policy
|
|
691
|
+
set_policy_release_groups pol
|
|
692
|
+
pol.save
|
|
693
|
+
|
|
694
|
+
# set scope targets of patch policy to all (in patch pols, 'all' means 'all eligible')
|
|
695
|
+
msg = "Jamf: Version '#{version}': Setting scope targets of patch policy to all eligible computers"
|
|
696
|
+
progress msg, log: :info
|
|
697
|
+
ppol = jamf_patch_policy
|
|
698
|
+
ppol.scope.set_all_targets
|
|
699
|
+
|
|
700
|
+
# if rollback, make sure the patch policy is set to 'allow downgrade'
|
|
701
|
+
if rollback
|
|
702
|
+
msg = "Jamf: Version '#{version}': Setting patch policy to allow downgrade"
|
|
703
|
+
progress msg, log: :info
|
|
704
|
+
ppol.allow_downgrade = true
|
|
705
|
+
else
|
|
706
|
+
ppol.allow_downgrade = false
|
|
707
|
+
end
|
|
708
|
+
ppol.save
|
|
709
|
+
|
|
710
|
+
# change status to 'released'
|
|
711
|
+
self.status = STATUS_RELEASED
|
|
712
|
+
self.release_date = Time.now
|
|
713
|
+
self.released_by = admin
|
|
714
|
+
chg_msg = rollback ? 'Version Released - Rolled Back' : 'Version Released'
|
|
715
|
+
log_change msg: chg_msg
|
|
716
|
+
|
|
717
|
+
save_local_data
|
|
718
|
+
ensure
|
|
719
|
+
unlock
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# deprecate this version
|
|
723
|
+
#
|
|
724
|
+
# @return [void]
|
|
725
|
+
#########################
|
|
726
|
+
def deprecate
|
|
727
|
+
lock
|
|
728
|
+
progress "Deprecating older released version '#{version}'"
|
|
729
|
+
disable_policies_for_deprecation_or_skipping :deprecated
|
|
730
|
+
self.status = STATUS_DEPRECATED
|
|
731
|
+
self.deprecation_date = Time.now
|
|
732
|
+
self.deprecated_by = admin
|
|
733
|
+
log_change msg: 'Version Deprecated'
|
|
734
|
+
|
|
735
|
+
save_local_data
|
|
736
|
+
ensure
|
|
737
|
+
unlock
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# skip this version
|
|
741
|
+
#
|
|
742
|
+
# @return [void]
|
|
743
|
+
#########################
|
|
744
|
+
def skip
|
|
745
|
+
lock
|
|
746
|
+
progress "Skipping unreleased version '#{version}'"
|
|
747
|
+
disable_policies_for_deprecation_or_skipping :skipped
|
|
748
|
+
self.status = STATUS_SKIPPED
|
|
749
|
+
self.skipped_date = Time.now
|
|
750
|
+
self.skipped_by = admin
|
|
751
|
+
log_change msg: 'Version Skipped'
|
|
752
|
+
save_local_data
|
|
753
|
+
ensure
|
|
754
|
+
unlock
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# Reset this version to 'pilot' status, since we are rolling back
|
|
758
|
+
# to a previous version
|
|
759
|
+
#
|
|
760
|
+
# @return [void]
|
|
761
|
+
#########################
|
|
762
|
+
def reset_to_pilot
|
|
763
|
+
return if status == STATUS_PILOT
|
|
764
|
+
|
|
765
|
+
lock
|
|
766
|
+
progress "Resetting version '#{version}' to pilot status due to rollback of an older version"
|
|
767
|
+
reset_policies_to_pilot
|
|
768
|
+
self.status = STATUS_PILOT
|
|
769
|
+
self.skipped_date = nil
|
|
770
|
+
self.skipped_by = nil
|
|
771
|
+
self.deprecation_date = nil
|
|
772
|
+
self.deprecated_by = nil
|
|
773
|
+
log_change msg: 'Version Reset to Pilot'
|
|
774
|
+
save_local_data
|
|
775
|
+
ensure
|
|
776
|
+
unlock
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# Update our instance attributes with any new data before
|
|
780
|
+
# saving the changes back out to the file system
|
|
781
|
+
# @return [void]
|
|
782
|
+
###########################
|
|
783
|
+
def update_local_instance_values
|
|
784
|
+
# update instance data with new data before writing out to the filesystem.
|
|
785
|
+
# Do this last so that the instance values can be compared to
|
|
786
|
+
# new_data_for_update in the steps above.
|
|
787
|
+
# Also, those steps might have updated some server-specific attributes
|
|
788
|
+
# which will be saved to the file system as well.
|
|
789
|
+
ATTRIBUTES.each do |attr, deets|
|
|
790
|
+
# make sure these are updated elsewhere if needed,
|
|
791
|
+
# e.g. modification data.
|
|
792
|
+
next if deets[:read_only]
|
|
793
|
+
next unless deets[:cli]
|
|
794
|
+
|
|
795
|
+
new_val = new_data_for_update[attr]
|
|
796
|
+
old_val = send(attr)
|
|
797
|
+
next if new_val == old_val
|
|
798
|
+
|
|
799
|
+
log_debug "Updating Xolo Version attribute '#{attr}': '#{old_val}' -> '#{new_val}'"
|
|
800
|
+
send "#{attr}=", new_val
|
|
801
|
+
end
|
|
802
|
+
# update any other server-specific attributes here...
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Save our current data out to our JSON data file
|
|
806
|
+
# This overwrites the existing data.
|
|
807
|
+
#
|
|
808
|
+
# @return [void]
|
|
809
|
+
##########################
|
|
810
|
+
def save_local_data
|
|
811
|
+
data_dir.mkpath
|
|
812
|
+
|
|
813
|
+
self.modification_date = Time.now
|
|
814
|
+
self.modified_by = admin
|
|
815
|
+
log_debug "Version '#{version}' of Title '#{title}' noting modification by #{modified_by}"
|
|
816
|
+
|
|
817
|
+
file = data_file
|
|
818
|
+
log_debug "Saving local version data to: #{file}"
|
|
819
|
+
file.pix_atomic_write to_json
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Delete the version
|
|
823
|
+
#
|
|
824
|
+
# @param update_title [Boolean] Update the title for this version to
|
|
825
|
+
# know the version is gone. Set this to false when the title itself
|
|
826
|
+
# is being deleted and calling this method.
|
|
827
|
+
#
|
|
828
|
+
# @return [void]
|
|
829
|
+
##########################
|
|
830
|
+
def delete(update_title: true)
|
|
831
|
+
lock
|
|
832
|
+
@current_action = :deleting
|
|
833
|
+
|
|
834
|
+
delete_patch_from_ted
|
|
835
|
+
delete_version_from_jamf
|
|
836
|
+
|
|
837
|
+
# remove from the title's list of versions
|
|
838
|
+
progress 'Deleting version from title data on the Xolo server', log: :debug
|
|
839
|
+
title_object.remove_version(version) if update_title
|
|
840
|
+
|
|
841
|
+
# delete the local data
|
|
842
|
+
progress 'Deleting version data from the Xolo server', log: :info
|
|
843
|
+
data_dir.rmtree
|
|
844
|
+
log_change msg: 'Version Deleted'
|
|
845
|
+
|
|
846
|
+
progress "Version '#{version}' of Title '#{title}' has been deleted from Xolo.", log: :info
|
|
847
|
+
ensure
|
|
848
|
+
unlock
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Is this version locked for updates?
|
|
852
|
+
#############################
|
|
853
|
+
def locked?
|
|
854
|
+
self.class.locked?(title, version)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Lock this version for updates
|
|
858
|
+
#############################
|
|
859
|
+
def lock
|
|
860
|
+
raise Xolo::ServerError, 'Server is shutting down' if Xolo::Server.shutting_down?
|
|
861
|
+
|
|
862
|
+
while locked?
|
|
863
|
+
log_debug "Waiting for update lock on Version '#{version}' of title '#{title}'..." if (Time.now.to_i % 5).zero?
|
|
864
|
+
sleep 0.33
|
|
865
|
+
end
|
|
866
|
+
Xolo::Server.object_locks[title] ||= { versions: {} }
|
|
867
|
+
|
|
868
|
+
exp = Time.now + Xolo::Server::ObjectLocks::OBJECT_LOCK_LIMIT
|
|
869
|
+
Xolo::Server.object_locks[title][:versions][version] = exp
|
|
870
|
+
log_debug "Locked version '#{version}' of title '#{title}' for updates until #{exp}"
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# Unlock this version for updates
|
|
874
|
+
#############################
|
|
875
|
+
def unlock
|
|
876
|
+
curr_lock = Xolo::Server.object_locks.dig title, :versions, version
|
|
877
|
+
return unless curr_lock
|
|
878
|
+
|
|
879
|
+
Xolo::Server.object_locks[title][:versions].delete version
|
|
880
|
+
log_debug "Unlocked version '#{version}' of title '#{title}' for updates"
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
# Add more data to our hash
|
|
884
|
+
###########################
|
|
885
|
+
def to_h
|
|
886
|
+
hash = super
|
|
887
|
+
|
|
888
|
+
# These attrs aren't defined in the ATTRIBUTES
|
|
889
|
+
# but we want them in the hash and/or JSON
|
|
890
|
+
hash[:jamf_pkg_id] = jamf_pkg_id
|
|
891
|
+
hash[:ted_id_number] = ted_id_number
|
|
892
|
+
hash[:pilot_groups_to_use] = pilot_groups_to_use
|
|
893
|
+
hash[:release_groups_to_use] = release_groups_to_use
|
|
894
|
+
|
|
895
|
+
hash.sort.to_h
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
end # class Version
|
|
899
|
+
|
|
900
|
+
end # module Server
|
|
901
|
+
|
|
902
|
+
end # module Xolo
|