promotion 1.0.7

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.
@@ -0,0 +1,322 @@
1
+ require 'etc'
2
+ require 'rexml/document'
3
+ require 'fileutils'
4
+
5
+ module Promotion # :nodoc:
6
+
7
+ # The Enforcer handles the first half of the promotion process: moving
8
+ # the files into place, setting permissions, ensuring that users and groups
9
+ # are set up correctly, etc.
10
+ class Enforcer
11
+
12
+ # returns the XML specification for the selected application
13
+ attr_reader :spec
14
+
15
+ # root
16
+ DEFAULT_FOLDER_OWNER = "root"
17
+ # wheel
18
+ DEFAULT_FOLDER_GROUP = "wheel"
19
+ # 0750
20
+ DEFAULT_FOLDER_MODE = "0750"
21
+ # false
22
+ DEFAULT_FOLDER_CLEAR = "false"
23
+ # root
24
+ DEFAULT_FILE_OWNER = "root"
25
+ # wheel
26
+ DEFAULT_FILE_GROUP = "wheel"
27
+ # 0640
28
+ DEFAULT_FILE_MODE = "0640"
29
+ # '.' the current folder
30
+ DEFAULT_FILE_SOURCE = "."
31
+
32
+ # Creates a new Enforcer for the selected application.
33
+ def initialize(appname)
34
+ appFolder = File.expand_path(appname, Folders::Staging)
35
+ Dir.chdir(appFolder)
36
+ specfile = File.expand_path(Files::Spec, appFolder)
37
+ unless File.exist?(specfile)
38
+ puts("\nSpecification file #{specfile} does not exist.\n" )
39
+ exit 1
40
+ end
41
+ doc = REXML::Document.new(File.new(specfile))
42
+ @spec = doc.root
43
+ $log.info("\n#{'_'*40}\nEnforcer created for #{appname}")
44
+ end
45
+
46
+ # Enforces the conditions needed for the selected app to function properly,
47
+ # as specified in the deployment descriptor:
48
+ # - ensure that certain groups exist
49
+ # - ensure that certain users exist (belonging to certain groups)
50
+ # - ensure that certain folders exist, if not create them; set permissions correctly;
51
+ # if desired, make sure they are empty
52
+ # - move specified files into place, set ownerships and permissions;
53
+ # create empty, or make empty, or preserve contents as desired.
54
+ # - ensure symbolic link exists, create if missing
55
+ # - create a zip archive of certain files if desired.
56
+ def start()
57
+ @spec.elements.each("/Specification/Groups/Group") { |group|
58
+ gid = group.attributes["Gid"].to_i
59
+ name = group.attributes["Name"]
60
+ create_group(gid, name) unless group_exist?(gid, name)
61
+ }
62
+ @spec.elements.each("/Specification/Users/User") { |user|
63
+ uid = user.attributes["Uid"].to_i
64
+ name = user.attributes["Name"]
65
+ gid = user.attributes["Gid"].to_i
66
+ uclass = user.attributes["Class"] || "default"
67
+ gecos = user.attributes["Gecos"] || ""
68
+ home = user.attributes["Home"] || "/home/#{name}"
69
+ shell = user.attributes["Shell"] || "/bin/ksh"
70
+ groups = user.attributes["Groups"] || ""
71
+ create_user(uid, name, gid, uclass, gecos, home, shell, groups) unless user_exist?(uid, name)
72
+ }
73
+ @spec.elements.each("/Specification/Folders/Folder[@Clear='true']") { |folder|
74
+ path = folder.text().strip()
75
+ clear_folder(path)
76
+ }
77
+ @spec.elements.each("/Specification/Folders/Folder") { |folder|
78
+ path = folder.text().strip()
79
+ owner = folder.attributes["Owner"] || folder.parent.attributes["Owner"] || DEFAULT_FOLDER_OWNER
80
+ group = folder.attributes["Group"] || folder.parent.attributes["Group"] || DEFAULT_FOLDER_GROUP
81
+ mode = folder.attributes["Mode"] || folder.parent.attributes["Mode"] || DEFAULT_FOLDER_MODE
82
+ clear = folder.attributes["Clear"] || folder.parent.attributes["Clear"] || DEFAULT_FOLDER_MODE
83
+ mode = mode.oct()
84
+ clear = (clear == "true")
85
+ ensure_folder(path, owner, group, mode, clear)
86
+ }
87
+ @spec.elements.each("/Specification/Files/File") { |file|
88
+ path = file.text().strip()
89
+ owner = file.attributes["Owner"] || file.parent.attributes["Owner"] || DEFAULT_FILE_OWNER
90
+ group = file.attributes["Group"] || file.parent.attributes["Group"] || DEFAULT_FILE_GROUP
91
+ mode = file.attributes["Mode"] || file.parent.attributes["Mode"] || DEFAULT_FILE_MODE
92
+ mode = mode.oct()
93
+ source = file.attributes["Source"] || file.parent.attributes["Source"] || DEFAULT_FILE_SOURCE
94
+ empty = file.attributes["Empty"] == "true"
95
+ overwrite = !(file.attributes["Overwrite"] == "false")
96
+ backup = file.attributes["Backup"] == "true"
97
+ ensure_file(path, owner, group, mode, source, empty, overwrite, backup)
98
+ }
99
+ @spec.elements.each("/Specification/Files/Link") { |link|
100
+ path = link.text().strip()
101
+ owner = link.attributes["Owner"] || link.parent.attributes["Owner"] || DEFAULT_FILE_OWNER
102
+ group = link.attributes["Group"] || link.parent.attributes["Group"] || DEFAULT_FILE_GROUP
103
+ mode = link.attributes["Mode"] || link.parent.attributes["Mode"] || DEFAULT_FILE_MODE
104
+ mode = mode.oct()
105
+ target = link.attributes["Target"] || link.parent.attributes["Source"] || DEFAULT_FILE_SOURCE
106
+ ensure_link(path, owner, group, mode, target)
107
+ }
108
+ @spec.elements.each("/Specification/Allfiles") { |file|
109
+ path = file.text().strip()
110
+ owner = file.attributes["Owner"] || file.parent.attributes["Owner"] || DEFAULT_FILE_OWNER
111
+ group = file.attributes["Group"] || file.parent.attributes["Group"] || DEFAULT_FILE_GROUP
112
+ mode = file.attributes["Mode"] || file.parent.attributes["Mode"] || DEFAULT_FILE_MODE
113
+ mode = mode.oct()
114
+ source = file.attributes["Source"] || file.parent.attributes["Source"] || DEFAULT_FILE_SOURCE
115
+ ensure_allfiles(path, owner, group, mode, source)
116
+ }
117
+ @spec.elements.each("/Specification/Zipfile") { |zipfile|
118
+ build_zip_file(zipfile)
119
+ }
120
+ end
121
+
122
+ # Detects if a user account exists with the given +uid+ and +name+
123
+ def user_exist?(uid, name)
124
+ begin
125
+ user1 = Etc.getpwuid(uid)
126
+ user2 = Etc.getpwnam(name)
127
+ raise unless user1 == user2
128
+ $log.info("User #{name}(#{uid}) already exists.")
129
+ rescue
130
+ return(false)
131
+ end
132
+ return(true)
133
+ end
134
+
135
+ # Creates a user account for the operating system
136
+ def create_user(uid, name, gid, uclass, gecos, home, shell, groups)
137
+ begin
138
+ FileUtils.mkdir_p(home) unless File.directory?(home) # ensure no warning about missing folder
139
+ command = Files::Useradd + " -u #{uid} -g #{gid} -L #{uclass} "
140
+ command += "-c '#{gecos}' -d #{home} -s #{shell} -p '*************' "
141
+ command += "-G #{groups} " if groups.length > 0
142
+ command += " #{name} "
143
+ raise unless system(command)
144
+ $log.info("User #{name}(#{uid})created.")
145
+ rescue => e
146
+ $log.error("Unable to create user #{name}(#{uid})\n#{e.message}")
147
+ exit 1
148
+ end
149
+ end
150
+
151
+ # Detects if a group exists with the given +gid+ and +name+
152
+ def group_exist?(gid, name)
153
+ begin
154
+ group1 = Etc.getgrgid(gid)
155
+ group2 = Etc.getgrnam(name)
156
+ raise unless group1 == group2
157
+ $log.info("Group #{name}(#{gid}) already exists.")
158
+ rescue
159
+ return(false)
160
+ end
161
+ return(true)
162
+ end
163
+
164
+ # Create a group in the operating system
165
+ def create_group(gid, name)
166
+ begin
167
+ command = Files::Groupadd + " -v -g #{gid} #{name}"
168
+ raise unless system(command)
169
+ $log.info("Group #{name}(#{gid}) created.")
170
+ rescue => e
171
+ $log.error("Unable to create group #{name}(#{gid})\n#{e.message}")
172
+ exit 1
173
+ end
174
+ end
175
+
176
+ # Removes a folder unconditionally
177
+ def clear_folder(path)
178
+ begin
179
+ FileUtils.rm_rf(path)
180
+ $log.info("Removed folder #{path} in preparation for a fresh installation.")
181
+ rescue
182
+ $log.error("Unable to remove folder #{path} prior to installation.")
183
+ exit 1
184
+ end
185
+ end
186
+
187
+ # Creates a folder (and any intermediate parent folders), sets the ownership
188
+ # and permissions
189
+ def ensure_folder(path, owner, group, mode, clear)
190
+ begin
191
+ FileUtils.mkdir_p(path) unless File.directory?(path)
192
+ FileUtils.chown(owner, group, path)
193
+ FileUtils.chmod(mode, path)
194
+ $log.info("Ensured folder #{path} is owned by #{owner}:#{group} with mode #{sprintf('%04o',mode)}")
195
+ rescue => e
196
+ $log.error("Unable to ensure folder #{path} is owned by #{owner}:#{group} with mode #{sprintf('%04o',mode)}\n#{e.message}")
197
+ exit 1
198
+ end
199
+ end
200
+
201
+ # Ensures that all specified files are moved into place with the correct ownership
202
+ # and permissions
203
+ def ensure_allfiles(path, owner, group, mode, source)
204
+ unless File.directory?(source)
205
+ $log.error("Missing source folder #{source} to install contents to #{path}")
206
+ exit 1
207
+ end
208
+ # modify the mode for folders so they are executable
209
+ perms = sprintf('%9b', mode)
210
+ perms[2] = "1"
211
+ perms[5] = "1"
212
+ perms[8] = "1"
213
+ folderMode = perms.to_i(2)
214
+ begin
215
+ Dir.chdir(source)
216
+ Dir["**/*"].each { |file|
217
+ targetPath = File.expand_path(file, path) # file could be app/media/shared/mypic.png
218
+ if File.directory?(file)
219
+ ensure_folder(targetPath, owner, group, folderMode, false)
220
+ else
221
+ ensure_file(targetPath, owner, group, mode, File.dirname(file), false, true)
222
+ end
223
+ }
224
+ rescue => e
225
+ $log.error("Unable to install allfiles from #{source} to #{path}\n#{e.message}")
226
+ exit 1
227
+ end
228
+ end
229
+
230
+ # Ensures that a file exists at the given path, with the
231
+ # ownership and permissions specified.
232
+ # The source file is derived from the source folder and path basename
233
+ # If the +empty+ parameter is +true+, create an empty file
234
+ # If <code>Overwrite="false"</code> then set ownerships and permissions but leave
235
+ # the contents alone (ie. create but no update)
236
+ # +path+ = full path to file to be created
237
+ # +source+ = folder within the project to copy file from
238
+ # If +backup+, preserve a copy of the original file if it has never been backed
239
+ # up yet (good for preserving original system config files)
240
+ def ensure_file(path, owner, group, mode, source, empty, overwrite=true, backup=false)
241
+ unless empty
242
+ sourceFile = File.expand_path(File.basename(path), source)
243
+ unless File.exist?(sourceFile)
244
+ $log.error("Missing source file #{sourceFile} to install to #{path}")
245
+ exit 1
246
+ end
247
+ end
248
+ begin
249
+ targetFolder = File.dirname(path)
250
+ unless File.directory?(targetFolder)
251
+ $log.warn("Missing folder #{targetFolder} is being created but may have wrong ownership and permissions.")
252
+ FileUtils.mkdir_p(targetFolder)
253
+ end
254
+ if empty
255
+ FileUtils.touch(path)
256
+ elsif !overwrite and File.exists?(path)
257
+ # preserve the contents
258
+ else
259
+ FileUtils.cp(path, path+"-original") if backup and not File.exist?(path+"-original")
260
+ FileUtils.cp(sourceFile, path)
261
+ end
262
+ FileUtils.chown(owner, group, path)
263
+ FileUtils.chmod(mode, path)
264
+ $log.info("Installed file #{path}, owned by #{owner}:#{group} with mode #{sprintf('%04o',mode)}")
265
+ rescue => e
266
+ $log.error("Unable to install file #{path}, owned by #{owner}:#{group} with mode #{sprintf('%04o',mode)}\n#{e.message}")
267
+ exit 1
268
+ end
269
+ end
270
+
271
+ # Ensures that a link exists at the given path, with the ownership and permissions specified.
272
+ def ensure_link(path, owner, group, mode, target)
273
+ begin
274
+ targetFolder = File.dirname(path)
275
+ unless File.directory?(targetFolder)
276
+ $log.warn("Missing folder #{targetFolder} is being created but may have wrong ownership and permissions.")
277
+ FileUtils.mkdir_p(targetFolder)
278
+ end
279
+ FileUtils.ln_sf(target, path)
280
+ FileUtils.chown(owner, group, path)
281
+ FileUtils.chmod(mode, path)
282
+ $log.info("Installed link #{path} --> #{target}, owned by #{owner}:#{group} with mode #{sprintf('%04o',mode)}")
283
+ rescue => e
284
+ $log.error("Unable to install link #{path} --> #{target}, owned by #{owner}:#{group} with mode #{sprintf('%04o',mode)}\n#{e.message}")
285
+ exit 1
286
+ end
287
+ end
288
+
289
+ # Creates a zip archive from the specified files
290
+ def build_zip_file(zipfile)
291
+ begin
292
+ path = zipfile.elements["Zip"].text().strip()
293
+ owner = zipfile.elements["Zip"].attributes["Owner"] || "root"
294
+ group = zipfile.elements["Zip"].attributes["Group"] || "wheel"
295
+ mode = zipfile.elements["Zip"].attributes["Mode"] || "0644"
296
+ mode = mode.oct()
297
+ filelist = []
298
+ zipfile.elements.each("Source") { |src|
299
+ sourceFile = src.text().strip()
300
+ unless File.exist?(sourceFile)
301
+ $log.error("Missing source file #{sourceFile} to add to zip archive #{path}")
302
+ exit 1
303
+ end
304
+ filelist << sourceFile
305
+ }
306
+ targetFolder = File.dirname(path)
307
+ unless File.directory?(targetFolder)
308
+ $log.warn("Missing folder #{targetFolder} is being created but may have wrong ownership and permissions.")
309
+ FileUtils.mkdir_p(targetFolder)
310
+ end
311
+ system("#{Files::Zip} #{path} #{filelist.join(' ')} ")
312
+ FileUtils.chown(owner, group, path)
313
+ FileUtils.chmod(mode, path)
314
+ $log.info("Created zip archive #{path}, owned by #{owner}:#{group} with mode #{sprintf('%04o',mode)}")
315
+ rescue => e
316
+ $log.error("Unable to create zip archive #{path}, owned by #{owner}:#{group} with mode #{sprintf('%04o',mode)}\n#{e.message}\n#{e.backtrace}")
317
+ exit 1
318
+ end
319
+ end
320
+
321
+ end
322
+ end
@@ -0,0 +1,19 @@
1
+ #
2
+ # /var/cron/tabs/<%= user %> - <%= user %>'s crontab
3
+ #
4
+ #minute hour mday month wday command
5
+ #
6
+ <% crontablist.each do |sched| %>
7
+ <% if sched.attributes["Comment"] %>
8
+ #______________________________
9
+ # <%= sched.attributes["Comment"] %>
10
+ <% end %>
11
+ <% minute = sched.attributes["Minute"] || "*" %>
12
+ <% hour = sched.attributes["Hour"] || "*" %>
13
+ <% mday = sched.attributes["DayOfMonth"] || "*" %>
14
+ <% month = sched.attributes["Month"] || "*" %>
15
+ <% wday = sched.attributes["DayOfWeek"] || "*" %>
16
+ <% cmd = (sched.elements["Command"].cdatas[0]).value().strip() %>
17
+ <%= minute + "\t" + hour + "\t" + mday + "\t" + month + "\t" + wday + "\t" + cmd %>
18
+
19
+ <% end %>
@@ -0,0 +1,17 @@
1
+ # This section of the file should not be edited
2
+ # It was generated by the promotion application and will be overwritten
3
+ # when the next promotion occurs.
4
+ # The previous section will be preserved
5
+ <% @specs.each do |spec|
6
+ spec.elements.each("/Specification/Environment") do |env|
7
+ env.elements.each("Variable") do |var|
8
+ t = var.cdatas.length > 0 ? var.cdatas[0].to_s() : var.text() %>
9
+ export <%= var.attributes["Name"] %>="<%= t.strip() %>"<% end %>
10
+ <% env.elements.each("Alias") do |ali|
11
+ t = ali.cdatas.length > 0 ? ali.cdatas[0].to_s() : ali.text()
12
+ %><% if ali.attributes["Comment"] %><%= "\n# "+ali.attributes["Comment"]
13
+ %><% end %>
14
+ alias <%= ali.attributes["Name"] %>='<%= t %>' <%
15
+ end
16
+ end
17
+ end %>
@@ -0,0 +1,20 @@
1
+ # This section of the file is generated by the promotion application
2
+ # DO NOT EDIT THIS SECTION - your changes will be lost
3
+ # You may edit the section before the promotion marker
4
+ #______________________________
5
+ # generated application flags
6
+ <% daemonSpecs = @specs.reject {|s| s.elements["/Specification/Daemon"].nil? } %>
7
+ <% daemonSpecs.sort!() do |a, b| %>
8
+ <% ap = a.elements["/Specification/Daemon"].attributes["Priority"].to_i || 10 %>
9
+ <% bp = b.elements["/Specification/Daemon"].attributes["Priority"].to_i || 10 %>
10
+ <% ap <=> bp %>
11
+ <% end %>
12
+ <% pkgScripts = [] %>
13
+ <% daemonSpecs.each do |spec| %>
14
+ <%= spec.attributes["Name"] %>_flags="<%= spec.elements["/Specification/Daemon"].attributes["Flags"] %>"
15
+ <% pkgScripts << spec.attributes["Name"] %>
16
+ <% end %>
17
+
18
+ # rc.d(8) packages scripts
19
+ # started in the specified order and stopped in reverse order
20
+ pkg_scripts="<%= pkgScripts.join(" ") %>"
@@ -0,0 +1,22 @@
1
+ # This section of the file should not be edited, even with visudo
2
+ # It was generated by the promotion application and will be overwritten
3
+ # when the next promotion occurs.
4
+ # The previous section will be preserved
5
+
6
+ Defaults timestamp_timeout=55
7
+
8
+ root ALL = (ALL) ALL
9
+ # people in group wheel may run all commands
10
+ %wheel ALL = (ALL) ALL
11
+
12
+ # Generated user privilege specifications
13
+ <% @specs.each do |spec| %>
14
+ <% spec.elements.each("/Specification/Sudoers/UserPrivilege") do |priv| %>
15
+ <%= sprintf('%-16s', priv.attributes["User"]) %>
16
+ <%= " ALL = " %>
17
+ <% if priv.attributes["Runas"] %>(<%= priv.attributes["Runas"] %>) <% end %>
18
+ <%= (priv.attributes["Password"] || "").downcase() == "true" ? " " : "NOPASSWD: " %>
19
+ <%= priv.text().strip() %>
20
+
21
+ <% end %>
22
+ <% end %>
@@ -0,0 +1,126 @@
1
+ module Promotion # :nodoc:
2
+ # The Evolver class evolves the database by executing migration scripts from the
3
+ # evolve folder of the project being promoted
4
+ #
5
+ # This class may be invoked via promote or via evolve or devolve commands
6
+ class Evolver
7
+
8
+ # Creates a new Evolver
9
+ def initialize(appname, evolve=true, targetVersion=nil)
10
+ @appname = appname
11
+ @evolve = (evolve == true)
12
+ @target = targetVersion.to_i()
13
+ @currentVersion = nil
14
+ @spec = get_spec()
15
+ db = @spec.elements["Database"]
16
+ @dbms = db.text()
17
+ @database = db.attributes["database"] || ""
18
+ end
19
+
20
+ # The deployment descriptor for an application should contain a single Database element
21
+ # in order to make use of the +evolve+ command. SQLite3 also needs a +database+
22
+ # attribute to specify the file to operate on.
23
+ # <Database>/usr/bin/mysql</Database>
24
+ # <Database database="/var/myapp/myapp.db">/usr/bin/sqlite3</Database>
25
+ def get_spec()
26
+ appFolder = File.expand_path(@appname, Folders::Staging)
27
+ Dir.chdir(appFolder)
28
+ specfile = File.expand_path(Files::Spec, appFolder)
29
+ unless File.exist?(specfile)
30
+ puts("\nSpecification file #{specfile} does not exist.\n" )
31
+ exit 1
32
+ end
33
+ doc = REXML::Document.new(File.new(specfile))
34
+ doc.root
35
+ end
36
+
37
+ # Gets the current version from the version file and calls evolve or devolve as required
38
+ def start()
39
+ versionFilename = File.expand_path("@version.#{@appname}", Folders::Staging)
40
+ if !File.exist?(versionFilename)
41
+ puts("We expect a version file at #{versionFilename} containing a single line")
42
+ puts("with the current version of the database (eg. 1001)")
43
+ exit 1
44
+ end
45
+ versionFile = File.new(versionFilename, 'r')
46
+ @currentVersion = versionFile.gets.chomp().to_i()
47
+ versionFile.close()
48
+ completed = @currentVersion
49
+ if @evolve
50
+ completed = evolve()
51
+ else
52
+ completed = devolve()
53
+ end
54
+ versionFile = File.new(versionFilename, 'w')
55
+ versionFile.puts(completed)
56
+ versionFile.close()
57
+ puts(" to version #{completed} ")
58
+ end
59
+
60
+ # Starts the database evolution:
61
+ # - find all of the relevant schema migration files in the +evolve+ folder
62
+ # - execute in sequence all migrations after the current version, updating the
63
+ # version file with the latest successful version number
64
+ def evolve()
65
+ $log.info("\n#{'_'*40}\nEvolving the database #{@database}\n")
66
+ evolveFolder = File.expand_path("#{@appname}/evolve", Folders::Staging)
67
+ Dir.chdir(evolveFolder)
68
+ migrations = Dir["*.sql"].collect { |f| f.sub(".sql","").to_i() }.sort()
69
+ migrations.reject! { |v| v <= @currentVersion }
70
+ migrations.reject! { |v| v > @target } unless @target == 0
71
+ @target = migrations.last.to_i
72
+ if @target > @currentVersion
73
+ print("Evolving the database")
74
+ else
75
+ puts("Already at version #{@currentVersion}.")
76
+ exit 0
77
+ end
78
+ completed = @currentVersion
79
+ migrations.each { |v|
80
+ success = system("#{@dbms} #{@database} < #{v}.sql")
81
+ if success
82
+ $log.info("Evolved the database to version #{v}")
83
+ completed = v
84
+ else
85
+ $log.error("Failed to evolve the database to version #{v}.")
86
+ break
87
+ end
88
+ }
89
+ return(completed)
90
+ end
91
+
92
+ # Returns the database to an earlier schema version:
93
+ # - find all of the relevant schema migration files in the +devolve+ folder
94
+ # - execute in sequence all migrations from the current version down to
95
+ # the target version
96
+ # - update the version file with the new version number
97
+ def devolve()
98
+ $log.info("\n#{'_'*40}\nDevolving the database #{@database}\n")
99
+ devolveFolder = File.expand_path("#{@appname}/devolve", Folders::Staging)
100
+ Dir.chdir(devolveFolder)
101
+ migrations = Dir["*.sql"].collect { |f| f.sub(".sql","").to_i() }.sort()
102
+ migrations.reject! { |v| v > @currentVersion }
103
+ migrations.reject! { |v| v <= @target } # after devolving we are at the previous version
104
+ migrations.reverse!
105
+ if @target < @currentVersion
106
+ print("Devolving the database")
107
+ else
108
+ puts("Already at version #{@currentVersion}.")
109
+ exit 0
110
+ end
111
+ completed = @currentVersion
112
+ migrations.each { |v|
113
+ success = system("#{@dbms} #{@database} < #{v}.sql")
114
+ if success
115
+ $log.info("Devolved the database from version #{v}")
116
+ completed = v
117
+ else
118
+ $log.error("Failed to devolve the database from version #{v}.")
119
+ break
120
+ end
121
+ }
122
+ return(completed-1) # after devolving we are at the previous version
123
+ end
124
+
125
+ end
126
+ end