Module: Xolo::Server::Mixins::Changelog

Included in:
Title, Version
Defined in:
lib/xolo/server/mixins/changelog.rb

Overview

This is mixed in to Xolo::Server::Title and Xolo::Server::Version, for simplified access to a title’s changelog

Each title has a changelog file that records changes to the title and its versions.

The changelog file is a ‘jsonlines’ file, which is a JSON file containing a single JSON object per line. See jsonlines.org/ for more info. The reason for using jsonlines is that it is easy to append to the file, rather than having to read the whole file into memory, parse it, add a new entry, and write it back.

In this case, each line is a JSON object (ruby Hash) representing a change or an action.

The keys in the hash are:

:time - the time the change was made
:admin - the admin who made the change
:host - the hostname or IP address of the admin
:version - the version number, or nil if the change is to the title
:attrib - the attribute name, or nil if the change is an action
:old - the original value, or nil if the change is an action
:new - the new value, or nil if the change is an action
:action - a description of the action, or nil if the change is to an attribute

The changelog file is stored in the title directory in a file named ‘changelog.json’. The file exists for as long as the title exists. It is backed up when before every event logged to it, in the backup directory in the server’s BACKUPS_DIR.

When a title is deleted, its changelog file is moved to a backup directory before the title directory is deleted, and will remain there until manually removed.

Constant Summary collapse

TITLE_CHANGELOG_FILENAME =

The change log filename

'changelog.jsonl'

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.backup_file_dirObject

When a title is deleted, its changelog is moved to this directory and renamed to ‘<title>_changelog.json’ This is so that the changelog can be accessed after the title is deleted.



69
70
71
# File 'lib/xolo/server/mixins/changelog.rb', line 69

def self.backup_file_dir
  @backup_file_dir ||= Xolo::Server::BACKUPS_DIR + 'changelogs'
end

.changelog_locksConcurrent::Hash

A hash of the read-write locks for each title’s changelog file The key is the title name, the value is the Concurrent::ReentrantReadWriteLock instance for that title.

Titles and versions use these locks to ensure that only one thread at a time can write to a title’s changelog file.

Returns:

  • (Concurrent::Hash)

    the locks



81
82
83
# File 'lib/xolo/server/mixins/changelog.rb', line 81

def self.changelog_locks
  @changelog_locks ||= Concurrent::Hash.new
end

.included(includer) ⇒ Object

when this module is included



61
62
63
# File 'lib/xolo/server/mixins/changelog.rb', line 61

def self.included(includer)
  Xolo.verbose_include includer, self
end

Instance Method Details

#backup_changelogvoid

This method returns an undefined value.

Copy the changelog file to the backup directory



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/xolo/server/mixins/changelog.rb', line 143

def backup_changelog
  return unless changelog_file.exist?

  unless changelog_backup_file.dirname.exist?
    log_debug 'Creating backup directory for changelogs'
    changelog_backup_file.dirname.mkpath
  end

  log_debug "Backing up changelog for #{title}"

  if changelog_backup_file.exist?
    # if deleting the whole title
    # move aside any previously existing one, appending a timestamp
    if self.class == Xolo::Server::Title && deleting?
      changelog_backup_file.rename "#{changelog_backup_file.basename}.#{changelog_backup_file.mtime.strftime('%Y%m%d%H%M%S')}"

      # otherwise, overwrite the current backup
    else
      changelog_backup_file.delete
    end

  end
  changelog_file.pix_cp changelog_backup_file
end

#changelogArray<Hash>

the change log for a title

Returns:

  • (Array<Hash>)

    the changelog



125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/xolo/server/mixins/changelog.rb', line 125

def changelog
  log_debug "Reading changelog for #{title}"
  changelog_data = []

  if changelog_file.exist?
    changelog_lock.with_read_lock do
      changelog_file.read.lines.each { |l| changelog_data << JSON.parse(l, symbolize_names: true) }
    end
  end

  # reverse the order so the most recent change is first
  changelog_data.reverse
end

#changelog_backup_filePathname

Returns the path to the backup file for this title’s changelog.

Returns:

  • (Pathname)

    the path to the backup file for this title’s changelog



101
102
103
# File 'lib/xolo/server/mixins/changelog.rb', line 101

def changelog_backup_file
  @changelog_backup_file ||= Xolo::Server::Mixins::Changelog.backup_file_dir + "#{title}-#{TITLE_CHANGELOG_FILENAME}"
end

#changelog_filePathname

the change log file for a title

Parameters:

  • title (String)

    the title

Returns:

  • (Pathname)

    the path to the file



95
96
97
# File 'lib/xolo/server/mixins/changelog.rb', line 95

def changelog_file
  @changelog_file ||= Xolo::Server::Title.title_dir(title) + TITLE_CHANGELOG_FILENAME
end

#changelog_lockConcurrent::ReentrantReadWriteLock

the read-write lock for a title’s changelog file

Parameters:

  • title (String)

    the title

Returns:

  • (Concurrent::ReentrantReadWriteLock)

    the lock



111
112
113
114
115
116
117
118
119
# File 'lib/xolo/server/mixins/changelog.rb', line 111

def changelog_lock
  @changelog_lock ||=
    if Xolo::Server::Mixins::Changelog.changelog_locks[title]
      Xolo::Server::Mixins::Changelog.changelog_locks[title]
    else
      log_debug "Creating changelog lock for #{title}"
      Xolo::Server::Mixins::Changelog.changelog_locks[title] = Concurrent::ReentrantReadWriteLock.new
    end
end

#delete_changelogvoid

This method returns an undefined value.

when a title is deleted, make a final entry, then move its changelog to the backup directory



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/xolo/server/mixins/changelog.rb', line 288

def delete_changelog
  change = {
    time: Time.now,
    admin: session[:admin],
    host: hostname_from_ip(server_app_instance.request.ip),
    version: nil,
    action: 'Title Deleted',
    attrib: nil,
    old: nil,
    new: nil
  }

  changelog_lock.with_write_lock do
    changelog_file.pix_append "#{change.to_json}\n"

    # final backup
    changelog_backup_file.delete if changelog_backup_file.exist?
    changelog_file.rename changelog_backup_file
  end
end

#hostname_from_ip(ip) ⇒ String

get a hostname from an IP address if possible

Parameters:

  • ip (String)

    the IP address

Returns:

  • (String)

    the hostname or the IP address if the hostname cannot be found



218
219
220
221
222
223
224
225
226
227
# File 'lib/xolo/server/mixins/changelog.rb', line 218

def hostname_from_ip(ip)
  # gethostbbaddr is deprecated, so use Resolv instead
  # host = Socket.gethostbyaddr(ip.split('.').map(&:to_i).pack('CCCC')).first

  host = Resolv.getname(ip)

  host.pix_empty? ? ip : host
rescue Resolv::ResolvError
  ip
end

#log_change(attrib: nil, old_val: nil, new_val: nil, msg: nil) ⇒ void

This method returns an undefined value.

Log a change by adding an entry to the changelog file for a title or one of its versions.

The entry may be for an message, such as ‘Title Created’, or for a change to the value of an attribute.

Provide either a message to log with :msg, or the name of an attribute being changed, with :attrib, and either :old_val, :new_val, or both. (either can be omitted or set to nil, when adding or removing the attribute)

Parameters:

  • attrib (Symbol) (defaults to: nil)

    the attribute name

  • old_val (Object) (defaults to: nil)

    the original value

  • new_val (Object) (defaults to: nil)

    the new value

  • msg (String) (defaults to: nil)

    an arbitrary message to log

Raises:

  • (ArgumentError)


186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/xolo/server/mixins/changelog.rb', line 186

def log_change(attrib: nil, old_val: nil, new_val: nil, msg: nil)
  raise ArgumentError, 'Must provide attrib: or action:' if !msg && !attrib
  raise ArgumentError, 'Must provide old: or new: or both with attrib:' if attrib && !old_val && !new_val

  # if action, attrib, old, and new are ignored
  attrib, old_val, new_val = nil if msg

  change = {
    time: Time.now,
    admin: session[:admin],
    host: hostname_from_ip(server_app_instance.request.ip),
    version: respond_to?(:version) ? version : nil,
    msg: msg,
    attrib: attrib,
    old: old_val,
    new: new_val
  }

  log_debug "Writing to changelog for #{title}"

  changelog_lock.with_write_lock do
    backup_changelog
    changelog_file.pix_append "#{change.to_json}\n"
  end
end

#log_update_changesvoid

This method returns an undefined value.

Record all changes during an update of a title or version



262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/xolo/server/mixins/changelog.rb', line 262

def log_update_changes
  return unless changes_for_update

  # self.class::ATTRIBUTES.each do |attr, deets|
  #   next unless deets[:changelog]

  #   new_val = deets[:type] == :time ? Time.parse(new_data_for_update[attr]) : new_data_for_update[attr]
  #   old_val = send attr

  #   new_val = "'#{new_val.sort.join("', '")}'" if new_val.is_a? Array
  #   old_val = "'#{old_val.sort.join("', '")}'" if old_val.is_a? Array
  #   next if new_val == old_val

  #   log_change attrib: attr, old_val: old_val, new_val: new_val
  # end

  changes_for_update.each do |attr, vals|
    log_change attrib: attr, old_val: vals[:old], new_val: vals[:new]
  end
end

#note_changes_for_update_and_logHash

At the start of an update, populate the hash for the @changes_for_update attribute with the changes being made.

This is run at the start of the update process, and

Returns:

  • (Hash)

    The changes being made



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/xolo/server/mixins/changelog.rb', line 235

def note_changes_for_update_and_log
  return unless new_data_for_update

  changes = {}

  self.class::ATTRIBUTES.each do |attr, deets|
    next unless deets[:changelog]

    new_val = deets[:type] == :time ? Time.parse(new_data_for_update[attr]) : new_data_for_update[attr]
    old_val = send attr

    # Don't change arrays to strings!
    # just sort them to compare
    new_val_to_compare =  new_val.is_a?(Array) ? new_val.sort : new_val
    old_val_to_compare =  old_val.is_a?(Array) ? old_val.sort : old_val
    next if new_val_to_compare == old_val_to_compare

    changes[attr] = { old: old_val, new: new_val }
  end

  changes
end