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
72
73
74
75
# File 'lib/xolo/server/mixins/changelog.rb', line 69

def self.backup_file_dir
  return @backup_file_dir if @backup_file_dir&.exist?

  @backup_file_dir = Xolo::Server::BACKUPS_DIR + 'changelogs'
  @backup_file_dir.mkpath unless @backup_file_dir.exist?
  @backup_file_dir
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



85
86
87
# File 'lib/xolo/server/mixins/changelog.rb', line 85

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



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

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



129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/xolo/server/mixins/changelog.rb', line 129

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



105
106
107
# File 'lib/xolo/server/mixins/changelog.rb', line 105

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



99
100
101
# File 'lib/xolo/server/mixins/changelog.rb', line 99

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



115
116
117
118
119
120
121
122
123
# File 'lib/xolo/server/mixins/changelog.rb', line 115

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



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/xolo/server/mixins/changelog.rb', line 281

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



224
225
226
227
228
229
230
231
232
233
# File 'lib/xolo/server/mixins/changelog.rb', line 224

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)


190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/xolo/server/mixins/changelog.rb', line 190

def log_change(attrib: nil, old_val: nil, new_val: nil, msg: nil)
  log_debug "Preparing to log change for #{title}: attrib=#{attrib.inspect}, old_val=#{old_val.inspect}, new_val=#{new_val.inspect}, msg=#{msg.inspect}"

  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.nil? && new_val.nil?

  # 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



268
269
270
271
272
273
274
# File 'lib/xolo/server/mixins/changelog.rb', line 268

def log_update_changes
  return unless changes_for_update

  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



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/xolo/server/mixins/changelog.rb', line 241

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