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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +177 -0
  3. data/README.md +7 -0
  4. data/bin/xoloserver +106 -0
  5. data/data/com.pixar.xoloserver.plist +29 -0
  6. data/data/uninstall-pkgs-by-id.zsh +103 -0
  7. data/lib/xolo/server/app.rb +133 -0
  8. data/lib/xolo/server/command_line.rb +216 -0
  9. data/lib/xolo/server/configuration.rb +739 -0
  10. data/lib/xolo/server/constants.rb +70 -0
  11. data/lib/xolo/server/helpers/auth.rb +257 -0
  12. data/lib/xolo/server/helpers/client_data.rb +415 -0
  13. data/lib/xolo/server/helpers/file_transfers.rb +265 -0
  14. data/lib/xolo/server/helpers/jamf_pro.rb +156 -0
  15. data/lib/xolo/server/helpers/log.rb +97 -0
  16. data/lib/xolo/server/helpers/maintenance.rb +401 -0
  17. data/lib/xolo/server/helpers/notification.rb +145 -0
  18. data/lib/xolo/server/helpers/pkg_signing.rb +141 -0
  19. data/lib/xolo/server/helpers/progress_streaming.rb +252 -0
  20. data/lib/xolo/server/helpers/title_editor.rb +92 -0
  21. data/lib/xolo/server/helpers/titles.rb +145 -0
  22. data/lib/xolo/server/helpers/versions.rb +160 -0
  23. data/lib/xolo/server/log.rb +286 -0
  24. data/lib/xolo/server/mixins/changelog.rb +315 -0
  25. data/lib/xolo/server/mixins/title_jamf_access.rb +1668 -0
  26. data/lib/xolo/server/mixins/title_ted_access.rb +519 -0
  27. data/lib/xolo/server/mixins/version_jamf_access.rb +1541 -0
  28. data/lib/xolo/server/mixins/version_ted_access.rb +373 -0
  29. data/lib/xolo/server/object_locks.rb +102 -0
  30. data/lib/xolo/server/routes/auth.rb +89 -0
  31. data/lib/xolo/server/routes/jamf_pro.rb +89 -0
  32. data/lib/xolo/server/routes/maint.rb +174 -0
  33. data/lib/xolo/server/routes/title_editor.rb +71 -0
  34. data/lib/xolo/server/routes/titles.rb +285 -0
  35. data/lib/xolo/server/routes/uploads.rb +93 -0
  36. data/lib/xolo/server/routes/versions.rb +261 -0
  37. data/lib/xolo/server/routes.rb +168 -0
  38. data/lib/xolo/server/title.rb +1143 -0
  39. data/lib/xolo/server/version.rb +902 -0
  40. data/lib/xolo/server.rb +205 -0
  41. data/lib/xolo-server.rb +8 -0
  42. 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