synqa 0.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.
data/README.rdoc ADDED
@@ -0,0 +1,31 @@
1
+ = synqa
2
+
3
+ *Synqa* is a simple file syncing tool that works over SSH, and is designed
4
+ primarily for maintaining static websites. It uses a hash function to
5
+ determine which files don't need to be copied because the destination copy
6
+ is already identical to the source copy.
7
+
8
+ I wrote it for two main reasons:
9
+
10
+ * I couldn't get *rsync* to work on the combination of Cygwin and my
11
+ hosting provider, and the rsync error messages were not very informative.
12
+ * It was an opportunity to learn about SSH and how to use SSH and SCP with Ruby.
13
+
14
+ == Dependencies of *synqa* are:
15
+
16
+ * Ruby 1.9.2
17
+ * An SSH client. I use *plink*.
18
+ * An SCP client. I use *pscp*.
19
+
20
+ For some sample code, see <b>examples/synga-useage.rb</b> and <b>examples/sample-rakefile</b>.
21
+
22
+ == Licence
23
+
24
+ Synqa is licensed under the GNU General Public License version 3.
25
+
26
+ == Notes and Issues
27
+
28
+ * *Synqa* has not been tested (or even designed to work) with file names
29
+ containing whitespace or non-ASCII characters. Typically this doesn't matter for
30
+ many static websites, but it will reduce the tool's usefulness as a general purpose
31
+ backup tool.
data/Rakefile ADDED
@@ -0,0 +1,53 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ begin
4
+ Bundler.setup(:default, :development)
5
+ rescue Bundler::BundlerError => e
6
+ $stderr.puts e.message
7
+ $stderr.puts "Run `bundle install` to install missing gems"
8
+ exit e.status_code
9
+ end
10
+ require 'rake'
11
+
12
+ require 'jeweler'
13
+ Jeweler::Tasks.new do |gem|
14
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
15
+ gem.name = "synqa"
16
+ gem.homepage = "http://github.com/pdorrell/synqa"
17
+ gem.license = "MIT"
18
+ gem.summary = %Q{Sync files from a local directory to a remote directory via SSH/SCP}
19
+ gem.description = %Q{Sync files from a local directory to a remote directory via SSH/SCP}
20
+ gem.email = "http://www.1729.com/email.html"
21
+ gem.authors = ["Philip Dorrell"]
22
+ # Include your dependencies below. Runtime dependencies are required when using your gem,
23
+ # and development dependencies are only needed for development (ie running rake tasks, tests, etc)
24
+ # gem.add_runtime_dependency 'jabber4r', '> 0.1'
25
+ # gem.add_development_dependency 'rspec', '> 1.2.3'
26
+ end
27
+ Jeweler::RubygemsDotOrgTasks.new
28
+
29
+ require 'rake/testtask'
30
+ Rake::TestTask.new(:test) do |test|
31
+ test.libs << 'lib' << 'test'
32
+ test.pattern = 'test/**/test_*.rb'
33
+ test.verbose = true
34
+ end
35
+
36
+ require 'rcov/rcovtask'
37
+ Rcov::RcovTask.new do |test|
38
+ test.libs << 'test'
39
+ test.pattern = 'test/**/test_*.rb'
40
+ test.verbose = true
41
+ end
42
+
43
+ task :default => :test
44
+
45
+ require 'rake/rdoctask'
46
+ Rake::RDocTask.new do |rdoc|
47
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
48
+
49
+ rdoc.rdoc_dir = 'rdoc'
50
+ rdoc.title = "synqa #{version}"
51
+ rdoc.rdoc_files.include('README*')
52
+ rdoc.rdoc_files.include('lib/**/*.rb')
53
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.0
data/_project.el ADDED
@@ -0,0 +1,9 @@
1
+
2
+ (load-this-project
3
+ `( (:ruby-executable ,*ruby-1.9-executable*)
4
+ (:run-project-command (ruby-run-file ,(concat (project-base-directory) "RunMain.rb")))
5
+ (:build-function project-compile-with-command)
6
+ (:compile-command "rake")
7
+ (:ruby-args ("-I."))
8
+ ) )
9
+
@@ -0,0 +1,63 @@
1
+
2
+ # A redacted rakefile example using Synqa
3
+ # In this example the source files are copied directly from ./src to
4
+ # yourusername@yourhostname.example.com/home/username/public.
5
+ #
6
+ # For a more complex static site, your rakefile might generate the site
7
+ # into an output directory and upload from there.
8
+ #
9
+ # Tasks:
10
+ # clean - removes the cached content files
11
+ # upload - syncs local content with remote dir (i.e. uploads new/changed files
12
+ # and deletes remote files that don't exist in the local dir
13
+ # uploaddry - a "dry run" for upload, doesn't actually upload or delete files
14
+
15
+ require 'rejinnirate-rake'
16
+ require 'synqa' # This assumes synqa is installed as a gem, otherwise require 'synqa.rb'
17
+ require 'digest/sha2'
18
+
19
+ STDOUT.sync = true
20
+
21
+ include Rejinnirate
22
+ include Synqa
23
+
24
+ BASE_DIR = File.dirname(__FILE__)
25
+
26
+ SRC_DIR = File.join(BASE_DIR, "src")
27
+ UPLOAD_DIR = SRC_DIR
28
+ SYNQA_DIR = File.join(BASE_DIR, "output", "synqa")
29
+
30
+ task :default => [:upload] do |t|
31
+ end
32
+
33
+ REMOTE_HOST = SshContentHost.new("yourusername@yourhostname.example.com",
34
+ Sha256Command.new(), "plink", "pscp")
35
+
36
+ REMOTE_SITE = RemoteContentLocation.new(REMOTE_HOST,
37
+ "/home/username/public",
38
+ File.join(SYNQA_DIR, "nearlyContent.txt"))
39
+
40
+ LOCAL_SITE = LocalContentLocation.new(UPLOAD_DIR,
41
+ Digest::SHA256,
42
+ File.join(SYNQA_DIR, "localContent.txt"),
43
+ :excludes => ["*\\~", "**/*\\~"])
44
+
45
+ task :init do |t|
46
+ ensureDirectoryExists(SYNQA_DIR)
47
+ end
48
+
49
+ task :clean => [:init] do |t|
50
+ SyncOperation.new(LOCAL_SITE, REMOTE_SITE).clearCachedContentFiles()
51
+ end
52
+
53
+ task :list do |t|
54
+ REMOTE_SITE.listFiles()
55
+ end
56
+
57
+ task :uploaddry => [:init] do |t|
58
+ SyncOperation.new(LOCAL_SITE, REMOTE_SITE).doSync(:dryRun => true)
59
+ end
60
+
61
+ task :upload => [:init] do |t|
62
+ SyncOperation.new(LOCAL_SITE, REMOTE_SITE).doSync()
63
+ end
@@ -0,0 +1,30 @@
1
+ # Sample code for synqa useage -- you will need to fill in your own details
2
+
3
+ require 'synqa.rb'
4
+ require 'digest/sha2'
5
+
6
+ STDOUT.sync = true
7
+
8
+ include Synqa
9
+ sha256Sum = Sha256SumCommand.new() # sha256sum (with 2 characters between hash and file name)
10
+ sha256 = Sha256Command.new() # sha256 -r (with 1 space between hash and file name)
11
+
12
+ localContentLocation = LocalContentLocation.new("c:/dev/src/project",
13
+ Digest::SHA256,
14
+ "c:/temp/synqa/local.project.content.cache.txt")
15
+
16
+ remoteHost = SshContentHost.new("username@host.example.com",
17
+ sha256, "plink", "pscp")
18
+
19
+ # Note: the specification of plink & pscp assumes that keys are managed with Pageant, and therefore
20
+ # do not need to be specified on the command line.
21
+
22
+ remoteContentLocation = RemoteContentLocation.new(remoteHost,
23
+ "/home/username/public",
24
+ "c:/temp/synqa/remote.project.content.cache.txt")
25
+
26
+ # Note: the cache files are currently written, but not yet used to speed up the sync
27
+
28
+ syncOperation = SyncOperation.new(localContentLocation, remoteContentLocation)
29
+
30
+ syncOperation.doSync(:dryRun => true) # set to false to make it actually happen
data/lib/synqa.rb ADDED
@@ -0,0 +1,738 @@
1
+ require 'time'
2
+
3
+ module Synqa
4
+
5
+ def checkProcessStatus(description)
6
+ processStatus = $?
7
+ if not processStatus.exited?
8
+ raise "#{description}: process did not exit normally"
9
+ end
10
+ exitStatus = processStatus.exitstatus
11
+ if exitStatus != 0
12
+ raise "#{description}: exit status = #{exitStatus}"
13
+ end
14
+ end
15
+
16
+ class RelativePathWithHash
17
+ attr_reader :relativePath, :hash
18
+
19
+ def initialize(relativePath, hash)
20
+ @relativePath = relativePath
21
+ @hash = hash
22
+ end
23
+
24
+ def inspect
25
+ return "RelativePathWithHash[#{relativePath}, #{hash}]"
26
+ end
27
+ end
28
+
29
+ class HashCommand
30
+
31
+ attr_reader :command, :length, :spacerLen
32
+
33
+ def initialize(command, length, spacerLen)
34
+ @command = command
35
+ @length = length
36
+ @spacerLen = spacerLen
37
+ end
38
+
39
+ def parseFileHashLine(baseDir, fileHashLine)
40
+ hash = fileHashLine[0...length]
41
+ fullPath = fileHashLine[(length + spacerLen)..-1]
42
+ if fullPath.start_with?(baseDir)
43
+ return RelativePathWithHash.new(fullPath[baseDir.length..-1], hash)
44
+ else
45
+ raise "File #{fullPath} from hash line is not in base dir #{baseDir}"
46
+ end
47
+ end
48
+
49
+ def to_s
50
+ return command.join(" ")
51
+ end
52
+ end
53
+
54
+ class Sha256SumCommand<HashCommand
55
+ def initialize
56
+ super(["sha256sum"], 64, 2)
57
+ end
58
+ end
59
+
60
+ class Sha256Command<HashCommand
61
+ def initialize
62
+ super(["sha256", "-r"], 64, 1)
63
+ end
64
+ end
65
+
66
+ def normalisedDir(baseDir)
67
+ return baseDir.end_with?("/") ? baseDir : baseDir + "/"
68
+ end
69
+
70
+ class DirContentHost
71
+
72
+ attr_reader :hashCommand, :pathPrefix
73
+
74
+ def initialize(hashCommand, pathPrefix = "")
75
+ @hashCommand = hashCommand
76
+ @pathPrefix = pathPrefix
77
+ end
78
+
79
+ def findDirectoriesCommand(baseDir)
80
+ return ["#{@pathPrefix}find", baseDir, "-type", "d", "-print"]
81
+ end
82
+
83
+ def listDirectories(baseDir)
84
+ baseDir = normalisedDir(baseDir)
85
+ command = findDirectoriesCommand(baseDir)
86
+ output = getCommandOutput(command)
87
+ directories = []
88
+ baseDirLen = baseDir.length
89
+ puts "Listing directories ..."
90
+ while (line = output.gets)
91
+ line = line.chomp
92
+ puts " #{line}"
93
+ if line.start_with?(baseDir)
94
+ directories << line[baseDirLen..-1]
95
+ else
96
+ raise "Directory #{line} is not a sub-directory of base directory #{baseDir}"
97
+ end
98
+ end
99
+ output.close()
100
+ checkProcessStatus(command)
101
+ return directories
102
+ end
103
+
104
+ def findFilesCommand(baseDir)
105
+ return ["#{@pathPrefix}find", baseDir, "-type", "f", "-print"]
106
+ end
107
+
108
+ def listFileHashes(baseDir)
109
+ baseDir = normalisedDir(baseDir)
110
+ fileHashes = []
111
+ listFileHashLines(baseDir) do |fileHashLine|
112
+ fileHash = self.hashCommand.parseFileHashLine(baseDir, fileHashLine)
113
+ if fileHash != nil
114
+ fileHashes << fileHash
115
+ end
116
+ end
117
+ return fileHashes
118
+ end
119
+
120
+ def getCommandOutput(command)
121
+ puts "#{command.inspect} ..."
122
+ return IO.popen(command)
123
+ end
124
+
125
+ def getContentTree(baseDir)
126
+ contentTree = ContentTree.new()
127
+ contentTree.time = Time.now.utc
128
+ for dir in listDirectories(baseDir)
129
+ contentTree.addDir(dir)
130
+ end
131
+ for fileHash in listFileHashes(baseDir)
132
+ contentTree.addFile(fileHash.relativePath, fileHash.hash)
133
+ end
134
+ return contentTree
135
+ end
136
+ end
137
+
138
+ class SshContentHost<DirContentHost
139
+
140
+ attr_reader :shell, :scpProgram, :host, :scpCommandString
141
+
142
+ def initialize(host, hashCommand, shell, scpProgram)
143
+ super(hashCommand)
144
+ @host = host
145
+ @shell = shell.is_a?(String) ? [shell] : shell
146
+ @scpProgram = scpProgram.is_a?(String) ? [scpProgram] : scpProgram
147
+ @scpCommandString = @scpProgram.join(" ")
148
+ end
149
+
150
+ def locationDescriptor(baseDir)
151
+ baseDir = normalisedDir(baseDir)
152
+ return "#{host}:#{baseDir} (connect = #{shell}/#{scpProgram}, hashCommand = #{hashCommand})"
153
+ end
154
+
155
+ def executeRemoteCommand(commandString, dryRun = false)
156
+ puts "SSH #{host} (#{shell.join(" ")}): executing #{commandString}"
157
+ if not dryRun
158
+ output = getCommandOutput(shell + [host, commandString])
159
+ while (line = output.gets)
160
+ yield line.chomp
161
+ end
162
+ output.close()
163
+ checkProcessStatus("SSH #{host} #{commandString}")
164
+ end
165
+ end
166
+
167
+ def ssh(commandString, dryRun = false)
168
+ executeRemoteCommand(commandString, dryRun) do |line|
169
+ puts line
170
+ end
171
+ end
172
+
173
+ def listDirectories(baseDir)
174
+ baseDir = normalisedDir(baseDir)
175
+ puts "Listing directories ..."
176
+ directories = []
177
+ baseDirLen = baseDir.length
178
+ executeRemoteCommand(findDirectoriesCommand(baseDir).join(" ")) do |line|
179
+ puts " #{line}"
180
+ if line.start_with?(baseDir)
181
+ directories << line[baseDirLen..-1]
182
+ else
183
+ raise "Directory #{line} is not a sub-directory of base directory #{baseDir}"
184
+ end
185
+ end
186
+ return directories
187
+ end
188
+
189
+ def listFileHashLines(baseDir)
190
+ baseDir = normalisedDir(baseDir)
191
+ remoteFileHashLinesCommand = findFilesCommand(baseDir) + ["|", "xargs", "-r"] + @hashCommand.command
192
+ executeRemoteCommand(remoteFileHashLinesCommand.join(" ")) do |line|
193
+ puts " #{line}"
194
+ yield line
195
+ end
196
+ end
197
+
198
+ def listFiles(baseDir)
199
+ baseDir = normalisedDir(baseDir)
200
+ executeRemoteCommand(findFilesCommand(baseDir).join(" ")) do |line|
201
+ puts " #{line}"
202
+ end
203
+ end
204
+
205
+ def getScpPath(path)
206
+ return host + ":" + path
207
+ end
208
+ end
209
+
210
+ class FileContent
211
+ attr_reader :name, :hash, :parentPathElements, :copyDestination, :toBeDeleted
212
+
213
+ def initialize(name, hash, parentPathElements)
214
+ @name = name
215
+ @hash = hash
216
+ @parentPathElements = parentPathElements
217
+ @copyDestination = nil
218
+ @toBeDeleted = false
219
+ end
220
+
221
+ def markToCopy(destinationDirectory)
222
+ @copyDestination = destinationDirectory
223
+ end
224
+
225
+ def markToDelete
226
+ @toBeDeleted = true
227
+ end
228
+
229
+ def to_s
230
+ return "#{name} (#{hash})"
231
+ end
232
+
233
+ def fullPath
234
+ return (parentPathElements + [name]).join("/")
235
+ end
236
+ end
237
+
238
+ class ContentTree
239
+ attr_reader :name, :pathElements, :files, :dirs, :fileByName, :dirByName
240
+ attr_reader :copyDestination, :toBeDeleted
241
+ attr_accessor :time
242
+
243
+ def initialize(name = nil, parentPathElements = nil)
244
+ @name = name
245
+ @pathElements = name == nil ? [] : parentPathElements + [name]
246
+ @files = []
247
+ @dirs = []
248
+ @fileByName = {}
249
+ @dirByName = {}
250
+ @copyDestination = nil
251
+ @toBeDeleted = false
252
+ @time = nil
253
+ end
254
+
255
+ def markToCopy(destinationDirectory)
256
+ @copyDestination = destinationDirectory
257
+ end
258
+
259
+ def markToDelete
260
+ @toBeDeleted = true
261
+ end
262
+
263
+ def fullPath
264
+ return @pathElements.join("/")
265
+ end
266
+
267
+ def getPathElements(path)
268
+ return path.is_a?(String) ? (path == "" ? [] : path.split("/")) : path
269
+ end
270
+
271
+ def getContentTreeForSubDir(subDir)
272
+ dirContentTree = dirByName.fetch(subDir, nil)
273
+ if dirContentTree == nil
274
+ dirContentTree = ContentTree.new(subDir, @pathElements)
275
+ dirs << dirContentTree
276
+ dirByName[subDir] = dirContentTree
277
+ end
278
+ return dirContentTree
279
+ end
280
+
281
+ def addDir(dirPath)
282
+ pathElements = getPathElements(dirPath)
283
+ if pathElements.length > 0
284
+ pathStart = pathElements[0]
285
+ restOfPath = pathElements[1..-1]
286
+ getContentTreeForSubDir(pathStart).addDir(restOfPath)
287
+ end
288
+ end
289
+
290
+ def sort!
291
+ dirs.sort_by! {|dir| dir.name}
292
+ files.sort_by! {|file| file.name}
293
+ for dir in dirs do
294
+ dir.sort!
295
+ end
296
+ end
297
+
298
+ def addFile(filePath, hash)
299
+ pathElements = getPathElements(filePath)
300
+ if pathElements.length == 0
301
+ raise "Invalid file path: #{filePath.inspect}"
302
+ end
303
+ if pathElements.length == 1
304
+ fileName = pathElements[0]
305
+ fileContent = FileContent.new(fileName, hash, @pathElements)
306
+ files << fileContent
307
+ fileByName[fileName] = fileContent
308
+ else
309
+ pathStart = pathElements[0]
310
+ restOfPath = pathElements[1..-1]
311
+ getContentTreeForSubDir(pathStart).addFile(restOfPath, hash)
312
+ end
313
+ end
314
+
315
+ @@dateTimeFormat = "%Y-%m-%d %H:%M:%S.%L %z"
316
+
317
+ def showIndented(name = "", indent = " ", currentIndent = "")
318
+ if time != nil
319
+ puts "#{currentIndent}[TIME: #{time.strftime(@@dateTimeFormat)}]"
320
+ end
321
+ if name != ""
322
+ puts "#{currentIndent}#{name}"
323
+ end
324
+ if copyDestination != nil
325
+ puts "#{currentIndent} [COPY to #{copyDestination.fullPath}]"
326
+ end
327
+ if toBeDeleted
328
+ puts "#{currentIndent} [DELETE]"
329
+ end
330
+ nextIndent = currentIndent + indent
331
+ for dir in dirs do
332
+ dir.showIndented("#{dir.name}/", indent = indent, currentIndent = nextIndent)
333
+ end
334
+ for file in files do
335
+ puts "#{nextIndent}#{file.name} - #{file.hash}"
336
+ if file.copyDestination != nil
337
+ puts "#{nextIndent} [COPY to #{file.copyDestination.fullPath}]"
338
+ end
339
+ if file.toBeDeleted
340
+ puts "#{nextIndent} [DELETE]"
341
+ end
342
+ end
343
+ end
344
+
345
+ def writeLinesToFile(outFile, prefix = "")
346
+ if time != nil
347
+ outFile.puts("T #{time.strftime(@@dateTimeFormat)}\n")
348
+ end
349
+ for dir in dirs do
350
+ outFile.puts("D #{prefix}#{dir.name}\n")
351
+ dir.writeLinesToFile(outFile, "#{prefix}#{dir.name}/")
352
+ end
353
+ for file in files do
354
+ outFile.puts("F #{file.hash} #{prefix}#{file.name}\n")
355
+ end
356
+ end
357
+
358
+ def writeToFile(fileName)
359
+ puts "Writing content tree to file #{fileName} ..."
360
+ File.open(fileName, "w") do |outFile|
361
+ writeLinesToFile(outFile)
362
+ end
363
+ end
364
+
365
+ @@dirLineRegex = /^D (.*)$/
366
+ @@fileLineRegex = /^F ([^ ]*) (.*)$/
367
+ @@timeRegex = /^T (.*)$/
368
+
369
+ def self.readFromFile(fileName)
370
+ contentTree = ContentTree.new()
371
+ puts "Reading content tree from #{fileName} ..."
372
+ IO.foreach(fileName) do |line|
373
+ dirLineMatch = @@dirLineRegex.match(line)
374
+ if dirLineMatch
375
+ dirName = dirLineMatch[1]
376
+ contentTree.addDir(dirName)
377
+ else
378
+ fileLineMatch = @@fileLineRegex.match(line)
379
+ if fileLineMatch
380
+ hash = fileLineMatch[1]
381
+ fileName = fileLineMatch[2]
382
+ contentTree.addFile(fileName, hash)
383
+ else
384
+ timeLineMatch = @@timeRegex.match(line)
385
+ if timeLineMatch
386
+ timeString = timeLineMatch[1]
387
+ contentTree.time = Time.strptime(timeString, @@dateTimeFormat)
388
+ else
389
+ raise "Invalid line in content tree file: #{line.inspect}"
390
+ end
391
+ end
392
+ end
393
+ end
394
+ return contentTree
395
+ end
396
+
397
+ def self.readMapOfHashesFromFile(fileName)
398
+ mapOfHashes = {}
399
+ time = nil
400
+ File.open(fileName).each_line do |line|
401
+ fileLineMatch = @@fileLineRegex.match(line)
402
+ if fileLineMatch
403
+ hash = fileLineMatch[1]
404
+ fileName = fileLineMatch[2]
405
+ mapOfHashes[fileName] = hash
406
+ end
407
+ timeLineMatch = @@timeRegex.match(line)
408
+ if timeLineMatch
409
+ timeString = timeLineMatch[1]
410
+ time = Time.strptime(timeString, @@dateTimeFormat)
411
+ end
412
+ end
413
+ return [time, mapOfHashes]
414
+ end
415
+
416
+ def markSyncOperationsForDestination(destination)
417
+ markCopyOperations(destination)
418
+ destination.markDeleteOptions(self)
419
+ end
420
+
421
+ def getDir(dir)
422
+ return dirByName.fetch(dir, nil)
423
+ end
424
+
425
+ def getFile(file)
426
+ return fileByName.fetch(file, nil)
427
+ end
428
+
429
+ def markCopyOperations(destinationDir)
430
+ for dir in dirs
431
+ destinationSubDir = destinationDir.getDir(dir.name)
432
+ if destinationSubDir != nil
433
+ dir.markCopyOperations(destinationSubDir)
434
+ else
435
+ dir.markToCopy(destinationDir)
436
+ end
437
+ end
438
+ for file in files
439
+ destinationFile = destinationDir.getFile(file.name)
440
+ if destinationFile == nil or destinationFile.hash != file.hash
441
+ file.markToCopy(destinationDir)
442
+ end
443
+ end
444
+ end
445
+
446
+ def markDeleteOptions(sourceDir)
447
+ for dir in dirs
448
+ sourceSubDir = sourceDir.getDir(dir.name)
449
+ if sourceSubDir == nil
450
+ dir.markToDelete()
451
+ else
452
+ dir.markDeleteOptions(sourceSubDir)
453
+ end
454
+ end
455
+ for file in files
456
+ sourceFile = sourceDir.getFile(file.name)
457
+ if sourceFile == nil
458
+ file.markToDelete()
459
+ end
460
+ end
461
+ end
462
+ end
463
+
464
+ class ContentLocation
465
+ attr_reader :cachedContentFile
466
+
467
+ def initialize(cachedContentFile)
468
+ @cachedContentFile = cachedContentFile
469
+ end
470
+
471
+ def getExistingCachedContentTreeFile
472
+ if cachedContentFile == nil
473
+ puts "No cached content file specified for location"
474
+ return nil
475
+ elsif File.exists?(cachedContentFile)
476
+ return cachedContentFile
477
+ else
478
+ puts "Cached content file #{cachedContentFile} does not yet exist."
479
+ return nil
480
+ end
481
+ end
482
+
483
+ def clearCachedContentFile
484
+ if cachedContentFile and File.exists?(cachedContentFile)
485
+ puts " deleting cached content file #{cachedContentFile} ..."
486
+ File.delete(cachedContentFile)
487
+ end
488
+ end
489
+
490
+ def getCachedContentTree
491
+ file = getExistingCachedContentTreeFile
492
+ if file
493
+ return ContentTree.readFromFile(file)
494
+ else
495
+ return nil
496
+ end
497
+ end
498
+
499
+ def getCachedContentTreeMapOfHashes
500
+ file = getExistingCachedContentTreeFile
501
+ if file
502
+ puts "Reading cached file hashes from #{file} ..."
503
+ return ContentTree.readMapOfHashesFromFile(file)
504
+ else
505
+ return [nil, {}]
506
+ end
507
+ end
508
+
509
+ end
510
+
511
+ class LocalContentLocation<ContentLocation
512
+ attr_reader :baseDir, :hashClass
513
+
514
+ def initialize(baseDir, hashClass, cachedContentFile = nil, options = {})
515
+ super(cachedContentFile)
516
+ @baseDir = normalisedDir(baseDir)
517
+ @baseDirLen = @baseDir.length
518
+ @hashClass = hashClass
519
+ @excludeGlobs = options.fetch(:excludes, [])
520
+ end
521
+
522
+ def getRelativePath(fileName)
523
+ if fileName.start_with? @baseDir
524
+ return fileName[@baseDirLen..-1]
525
+ else
526
+ raise "File name #{fileName} does not start with #{baseDir}"
527
+ end
528
+ end
529
+
530
+ def getScpPath(relativePath)
531
+ return getFullPath(relativePath)
532
+ end
533
+
534
+ def getFullPath(relativePath)
535
+ return @baseDir + relativePath
536
+ end
537
+
538
+ def fileIsExcluded(relativeFile)
539
+ for excludeGlob in @excludeGlobs
540
+ if File.fnmatch(excludeGlob, relativeFile)
541
+ puts " file #{relativeFile} excluded by glob #{excludeGlob}"
542
+ return true
543
+ end
544
+ end
545
+ return false
546
+ end
547
+
548
+ def getContentTree
549
+ cachedTimeAndMapOfHashes = getCachedContentTreeMapOfHashes
550
+ cachedTime = cachedTimeAndMapOfHashes[0]
551
+ cachedMapOfHashes = cachedTimeAndMapOfHashes[1]
552
+ contentTree = ContentTree.new()
553
+ contentTree.time = Time.now.utc
554
+ #puts "LocalContentLocation.getContentTree for baseDir #{baseDir} ..."
555
+ for fileOrDir in Dir.glob(baseDir + "**/*", File::FNM_DOTMATCH)
556
+ if not (fileOrDir.end_with?("/.") or fileOrDir.end_with?("/.."))
557
+ relativePath = getRelativePath(fileOrDir)
558
+ #puts " #{relativePath}"
559
+ if File.directory? fileOrDir
560
+ contentTree.addDir(relativePath)
561
+ else
562
+ if not fileIsExcluded(relativePath)
563
+ cachedDigest = cachedMapOfHashes[relativePath]
564
+ if cachedTime and cachedDigest and File.stat(fileOrDir).mtime < cachedTime
565
+ digest = cachedDigest
566
+ else
567
+ digest = hashClass.file(fileOrDir).hexdigest
568
+ end
569
+ contentTree.addFile(relativePath, digest)
570
+ end
571
+ end
572
+ end
573
+ end
574
+ contentTree.sort!
575
+ if cachedContentFile != nil
576
+ contentTree.writeToFile(cachedContentFile)
577
+ end
578
+ return contentTree
579
+ end
580
+ end
581
+
582
+ class RemoteContentLocation<ContentLocation
583
+ attr_reader :host, :baseDir
584
+
585
+ def initialize(host, baseDir, cachedContentFile = nil)
586
+ super(cachedContentFile)
587
+ @host = host
588
+ @baseDir = normalisedDir(baseDir)
589
+ end
590
+
591
+ def listFiles()
592
+ host.listFiles(baseDir)
593
+ end
594
+
595
+ def scpCommandString
596
+ return host.scpCommandString
597
+ end
598
+
599
+ def getFullPath(relativePath)
600
+ return baseDir + relativePath
601
+ end
602
+
603
+ def getScpPath(relativePath)
604
+ return host.getScpPath(getFullPath(relativePath))
605
+ end
606
+
607
+ def ssh(commandString, dryRun = false)
608
+ host.ssh(commandString, dryRun)
609
+ end
610
+
611
+ def listDirectories
612
+ return host.listDirectories(baseDir)
613
+ end
614
+
615
+ def listFileHashes
616
+ return host.listFileHashes(baseDir)
617
+ end
618
+
619
+ def to_s
620
+ return host.locationDescriptor(baseDir)
621
+ end
622
+
623
+ def getContentTree
624
+ if cachedContentFile and File.exists?(cachedContentFile)
625
+ return ContentTree.readFromFile(cachedContentFile)
626
+ else
627
+ contentTree = host.getContentTree(baseDir)
628
+ contentTree.sort!
629
+ if cachedContentFile != nil
630
+ contentTree.writeToFile(cachedContentFile)
631
+ end
632
+ return contentTree
633
+ end
634
+ end
635
+
636
+ end
637
+
638
+ class SyncOperation
639
+ attr_reader :sourceLocation, :destinationLocation
640
+
641
+ def initialize(sourceLocation, destinationLocation)
642
+ @sourceLocation = sourceLocation
643
+ @destinationLocation = destinationLocation
644
+ end
645
+
646
+ def getContentTrees
647
+ @sourceContent = @sourceLocation.getContentTree()
648
+ @destinationContent = @destinationLocation.getContentTree()
649
+ end
650
+
651
+ def markSyncOperations
652
+ @sourceContent.markSyncOperationsForDestination(@destinationContent)
653
+ puts " ================================================ "
654
+ puts "After marking for sync --"
655
+ puts ""
656
+ puts "Local:"
657
+ @sourceContent.showIndented()
658
+ puts ""
659
+ puts "Remote:"
660
+ @destinationContent.showIndented()
661
+ end
662
+
663
+ def clearCachedContentFiles
664
+ @sourceLocation.clearCachedContentFile()
665
+ @destinationLocation.clearCachedContentFile()
666
+ end
667
+
668
+ def doSync(options = {})
669
+ if options[:full]
670
+ clearCachedContentFiles()
671
+ end
672
+ getContentTrees()
673
+ markSyncOperations()
674
+ dryRun = options[:dryRun]
675
+ if not dryRun
676
+ @destinationLocation.clearCachedContentFile()
677
+ end
678
+ doAllCopyOperations(dryRun)
679
+ doAllDeleteOperations(dryRun)
680
+ if (@destinationLocation.cachedContentFile and @sourceLocation.cachedContentFile and
681
+ File.exists?(@sourceLocation.cachedContentFile))
682
+ FileUtils::Verbose.cp(@sourceLocation.cachedContentFile, @destinationLocation.cachedContentFile)
683
+ end
684
+ end
685
+
686
+ def doAllCopyOperations(dryRun)
687
+ doCopyOperations(@sourceContent, @destinationContent, dryRun)
688
+ end
689
+
690
+ def doAllDeleteOperations(dryRun)
691
+ doDeleteOperations(@destinationContent, dryRun)
692
+ end
693
+
694
+ def executeCommand(command, dryRun)
695
+ puts "EXECUTE: #{command}"
696
+ if not dryRun
697
+ system(command)
698
+ checkProcessStatus(command)
699
+ end
700
+ end
701
+
702
+ def doCopyOperations(sourceContent, destinationContent, dryRun)
703
+ for dir in sourceContent.dirs do
704
+ if dir.copyDestination != nil
705
+ sourcePath = sourceLocation.getScpPath(dir.fullPath)
706
+ destinationPath = destinationLocation.getScpPath(dir.copyDestination.fullPath)
707
+ executeCommand("#{destinationLocation.scpCommandString} -r #{sourcePath} #{destinationPath}", dryRun)
708
+ else
709
+ doCopyOperations(dir, destinationContent.getDir(dir.name), dryRun)
710
+ end
711
+ end
712
+ for file in sourceContent.files do
713
+ if file.copyDestination != nil
714
+ sourcePath = sourceLocation.getScpPath(file.fullPath)
715
+ destinationPath = destinationLocation.getScpPath(file.copyDestination.fullPath)
716
+ executeCommand("#{destinationLocation.scpCommandString} #{sourcePath} #{destinationPath}", dryRun)
717
+ end
718
+ end
719
+ end
720
+
721
+ def doDeleteOperations(destinationContent, dryRun)
722
+ for dir in destinationContent.dirs do
723
+ if dir.toBeDeleted
724
+ dirPath = destinationLocation.getFullPath(dir.fullPath)
725
+ destinationLocation.ssh("rm -r #{dirPath}", dryRun)
726
+ else
727
+ doDeleteOperations(dir, dryRun)
728
+ end
729
+ end
730
+ for file in destinationContent.files do
731
+ if file.toBeDeleted
732
+ filePath = destinationLocation.getFullPath(file.fullPath)
733
+ destinationLocation.ssh("rm #{filePath}", dryRun)
734
+ end
735
+ end
736
+ end
737
+ end
738
+ end