qdumpfs 0.4.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.
@@ -0,0 +1,31 @@
1
+ # Qdumpfs
2
+
3
+ qdumpfs is a modified version of pdumpfs.
4
+
5
+ ## Installation
6
+
7
+ gem install qdumpfs
8
+
9
+ ```ruby
10
+ gem 'qdumpfs'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install qdumpfs
20
+
21
+ ## Usage
22
+
23
+ qdumpfs srcdir dstdir
24
+
25
+ ## License
26
+
27
+ qdumpfs is a free software with ABSOLUTELY NO WARRANTY under the terms of the GNU General Public License version 2.
28
+
29
+
30
+
31
+
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,2 @@
1
+ ・引数なしで実行したときに例外が表示される
2
+ ・自動テストを追加する
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "qdumpfs"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,14 @@
1
+ @echo off
2
+
3
+ call uru 265p114
4
+
5
+ set bundle_dir=./vendor/bundle
6
+
7
+ IF EXIST "vendor/bundle/" (
8
+ echo update
9
+ rmdir /s /q vendor\bundle
10
+ bundle update
11
+ ) ELSE (
12
+ echo install
13
+ bundle install --path %bundle_dir%
14
+ )
@@ -0,0 +1,19 @@
1
+ #!/bin/sh
2
+ #bundle config build.libv8 --with-system-v8
3
+ #bundle config build.therubyracer --with-v8-dir
4
+ set -x
5
+ #export NOKOGIRI_USE_SYSTEM_LIBRARIES=1
6
+
7
+ bundle_dir=./vendor/bundle
8
+ if [ -d "$bundle_dir" ] ; then
9
+ /bin/rm -rf "$bundle_dir"
10
+ bundle update
11
+ else
12
+ /bin/rm -rf "$bundle_dir"
13
+ bundle install --path "$bundle_dir"
14
+ fi
15
+
16
+
17
+
18
+
19
+
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "qdumpfs"
4
+
5
+ Qdumpfs::Command.run(ARGV)
6
+
@@ -0,0 +1,540 @@
1
+ # coding: utf-8
2
+ require "qdumpfs/version"
3
+ require "qdumpfs/win32"
4
+ require "qdumpfs/util"
5
+ require "qdumpfs/option"
6
+ require 'find'
7
+ require 'optparse'
8
+ require 'date'
9
+ require 'fileutils'
10
+
11
+
12
+ module Qdumpfs
13
+
14
+ class Command
15
+ include QdumpfsUtils
16
+
17
+ def self.run(argv)
18
+ STDOUT.sync = true
19
+ opts = {}
20
+ opt = OptionParser.new(argv)
21
+ opt.version = VERSION
22
+ opt.banner = "Usage: #{opt.program_name} [-h|--help]"
23
+ opt.separator('')
24
+ opt.on_head('-h', '--help', 'show this message') do |v|
25
+ puts opt.help
26
+ exit
27
+ end
28
+ opt.on('-v', '--verbose', 'verbose message') {|v| opts[:v] = v}
29
+ opt.on('-r', '--report', 'report message') {|v| opts[:r] = v}
30
+ opt.on('-n', '--dry-run', "don't actually run any commands") {|v| opts[:n] = v}
31
+ opt.on('-e PATTERN', '--exclude=PATTERN', 'exclude files/directories matching PATTERN') {|v|
32
+ opts[:ep] = [] if opts[:ep].nil?
33
+ opts[:ep] << Regexp.new(v)
34
+ }
35
+ opt.on('-s SIZE', '--exclude-by-size=SIZE', 'exclude files larger than SIZE') {|v| opts[:es] = v }
36
+ opt.on('-w GLOB', '--exclude-by-glob=GLOB', 'exclude files matching GLOB') {|v| opts[:ep] = v }
37
+ commands = ['backup', 'sync', 'list', 'expire', 'verify', 'test']
38
+ opt.on('-c COMMAND', '--command=COMMAND', commands, commands.join('|')) {|v| opts[:c] = v}
39
+ opt.on('-l HOURS', '--limit=HOURS', 'limit hours') {|v| opts[:limit] = v}
40
+ opt.on('-k KEEPARG', '--keep=KEEPARG', 'ex: --keep 100Y12M12W30D (100years, 12months, 12weeks, 30days, default)') {|v| opts[:keep] = v}
41
+
42
+
43
+ opt.parse!(argv)
44
+ option = Option.new(opts, ARGV)
45
+
46
+ begin
47
+ command = Command.new(option)
48
+ command.run
49
+ rescue ArgumentError => e
50
+ puts e.message, e.backtrace
51
+ puts opt.help
52
+ exit
53
+ rescue => e
54
+ puts e.message, e.backtrace
55
+ end
56
+ end
57
+
58
+ def initialize(opt)
59
+ @opt = opt
60
+ end
61
+
62
+ def run
63
+ if @opt.cmd == 'backup'
64
+ backup
65
+ elsif @opt.cmd == 'sync'
66
+ sync
67
+ elsif @opt.cmd == 'list'
68
+ list
69
+ elsif @opt.cmd == 'expire'
70
+ expire
71
+ elsif @opt.cmd == 'verify'
72
+ verify
73
+ elsif @opt.cmd == 'test'
74
+ test
75
+ else
76
+ raise RuntimeError, "unknown command: #{cmd}"
77
+ end
78
+ end
79
+
80
+ private
81
+ def log_result(src, today, elapsed)
82
+ time = Time.now.strftime("%Y-%m-%dT%H:%M:%S")
83
+ bytes = convert_bytes(@written_bytes)
84
+ msg = sprintf("%s: %s -> %s (in %.2f sec, %s written)\n",
85
+ time, src, today, elapsed, bytes)
86
+ log(msg)
87
+ end
88
+
89
+ def log(msg, console = true)
90
+ @opt.log(msg, console)
91
+ end
92
+
93
+ def report(type, file_name)
94
+ @opt.report(type, file_name)
95
+ end
96
+
97
+ def update_file(src, latest, today)
98
+ type = detect_type(src, latest)
99
+ report(type, src)
100
+ return if @opt.dry_run
101
+ case type
102
+ when "directory"
103
+ FileUtils.mkpath(today)
104
+ when "unchanged"
105
+ File.force_link(latest, today)
106
+ when "updated"
107
+ copy(src, today)
108
+ when "new_file"
109
+ copy(src, today)
110
+ when "symlink"
111
+ File.force_symlink(File.readlink(src), today)
112
+ when "unsupported"
113
+ # just ignore it
114
+ else
115
+ raise "#{type}: shouldn't be reached here"
116
+ end
117
+ chown_if_root(type, src, today)
118
+ end
119
+
120
+ def filecount(dir)
121
+ pscmd = 'Get-ChildItem -Recurse -File | Measure-Object | %{$_.Count}'
122
+ cmd = "powershell -Command \"#{pscmd}\""
123
+ result = nil
124
+ Dir.chdir(dir) do
125
+ result = `#{cmd}`
126
+ result.chomp!
127
+ end
128
+ result.to_i
129
+ end
130
+
131
+ def do_verify(src, dst)
132
+ src_count = filecount(src)
133
+ dst_count= filecount(dst)
134
+ return src_count, dst_count
135
+ end
136
+
137
+ def get_snapshots(target_dir)
138
+ # 指定したディレクトリに含まれるバックアップフォルダ(日付つき)を全て取得
139
+ dd = "[0-9][0-9]"
140
+ dddd = dd + dd
141
+ # FIXME: Y10K problem.
142
+ dirs = []
143
+ glob_path = File.join(target_dir, dddd, dd, dd)
144
+ Dir.glob(glob_path).sort.find {|dir|
145
+ day, month, year = File.split_all(dir).reverse.map {|x| x.to_i }
146
+ path = dir
147
+ if File.directory?(path) and Date.valid_date?(year, month, day) and
148
+ dirs << path
149
+ end
150
+ }
151
+ dirs
152
+ end
153
+
154
+ def get_snapshot_date(snapshot)
155
+ # バックアップディレクトリのパス(日付つき)から日付を取得して返す
156
+ day, month, year = File.split_all(snapshot).reverse.map {|x| x.to_i }
157
+ Time.new(year, month, day)
158
+ end
159
+
160
+ def update_snapshot(src, latest, today)
161
+ # バックアップの差分コピーを実行
162
+ # src: コピー元ディレクトリ ex) i:/from/home
163
+ # latest: 最新のバックアップディレクトリ ex)j:/to/backup1/2019/05/09/home
164
+ # today: 差分バックアップ先ディレクトリ ex)j:/to/backup1/2019/05/10/home
165
+ dirs = {};
166
+ QdumpfsFind.find(@opt.logger, src) do |s| # path of the source file
167
+ if @opt.matcher.exclude?(s)
168
+ if File.lstat(s).directory? then Find.prune() else next end
169
+ end
170
+ # バックアップ元ファイルのパスからディレクトリ部分を削除
171
+ r = make_relative_path(s, src)
172
+ # 既存バックアップファイルのパス
173
+ l = File.join(latest, r) # path of the latest snapshot
174
+ # 新規バックアップファイルのパス
175
+ t = File.join(today, r) # path of the today's snapshot
176
+ begin
177
+ # ファイルのアップデート
178
+ update_file(s, l, t)
179
+ dirs[t] = File.stat(s) if File.ftype(s) == "directory"
180
+ rescue Errno::ENOENT, Errno::EACCES => e
181
+ wprintf("%s: %s", src, e.message)
182
+ next
183
+ end
184
+ end
185
+ return if @opt.dry_run
186
+ restore_dir_attributes(dirs)
187
+ end
188
+
189
+ def recursive_copy(src, dst)
190
+ dirs = {}
191
+ QdumpfsFind.find(@opt.logger, src) do |s|
192
+ if @opt.matcher.exclude?(s)
193
+ if File.lstat(s).directory? then Find.prune() else next end
194
+ end
195
+ r = make_relative_path(s, src)
196
+ t = File.join(dst, r)
197
+ begin
198
+ type = detect_type(s)
199
+ report(type, s)
200
+ next if @opt.dry_run
201
+ case type
202
+ when "directory"
203
+ FileUtils.mkpath(t)
204
+ when "new_file"
205
+ copy(s, t)
206
+ when "symlink"
207
+ File.force_symlink(File.readlink(s), t)
208
+ when "unsupported"
209
+ # just ignore it
210
+ else
211
+ raise "#{type}: shouldn't be reached here"
212
+ end
213
+ chown_if_root(type, s, t)
214
+ dirs[t] = File.stat(s) if File.ftype(s) == "directory"
215
+ rescue Errno::ENOENT, Errno::EACCES => e
216
+ wprintf("%s: %s", s, e.message)
217
+ next
218
+ end
219
+ end
220
+ restore_dir_attributes(dirs) unless @opt.dry_run
221
+ end
222
+
223
+ def sync_latest(src, dst, base = nil)
224
+ # pdumpfsのバックアップフォルダを同期する
225
+
226
+ #コピー元のスナップショット
227
+ src_snapshots = BackupDir.scan_backup_dirs(src)
228
+ @opt.detect_keep_dirs(src_snapshots)
229
+
230
+ # コピー先の最新スナップショット
231
+ dst_snapshots = BackupDir.scan_backup_dirs(dst)
232
+ dst_snapshot = dst_snapshots[-1]
233
+
234
+ # コピー元フォルダの決定
235
+ src_snapshot = nil
236
+ src_snapshots.each do |snapshot|
237
+ next if dst_snapshot && snapshot.date <= dst_snapshot.date
238
+ if snapshot.keep
239
+ src_snapshot = snapshot
240
+ break
241
+ end
242
+ end
243
+
244
+ if src_snapshot.nil?
245
+ return false, nil, nil
246
+ end
247
+
248
+ # 今回コピーするフォルダの名前
249
+ src = src_snapshot.path
250
+ today = File.join(dst, datedir(src_snapshot.date))
251
+ latest = dst_snapshot ? File.join(dst_snapshot.path) : nil
252
+
253
+ # src: j: /to/backup1/2019/05/10/home/"
254
+ # latest:
255
+ # today: j:/sync/backup1/2019/05/10/home"
256
+ log("sync_latest src=#{src} latest=#{latest} today=#{today}")
257
+
258
+ if latest
259
+ log("update_snapshot #{src} #{latest} #{today}")
260
+ # バックアップがすでに存在する場合差分コピー
261
+ update_snapshot(src, latest, today)
262
+ else
263
+ log("recursive_copy #{src}=>#{today}")
264
+ # 初回は単純に再帰コピー
265
+ recursive_copy(src, today)
266
+ end
267
+
268
+ return true, src, today
269
+ end
270
+
271
+ def latest_snapshot(start_time, src, dst, base)
272
+ # バックアップ先の日付ディレクトリを取得
273
+ # 現在の日付より過去のもののなかで最新を取得する(なければnil。現在の日付しかなくてもnil)
274
+ dd = "[0-9][0-9]"
275
+ dddd = dd + dd
276
+ # FIXME: Y10K problem.
277
+ glob_path = File.join(dst, dddd, dd, dd)
278
+ Dir.glob(glob_path).sort {|a, b| b <=> a }.find {|dir|
279
+ day, month, year = File.split_all(dir).reverse.map {|x| x.to_i }
280
+ path = File.join(dir, base)
281
+ if File.directory?(path) and Date.valid_date?(year, month, day) and
282
+ past_date?(year, month, day, start_time)
283
+ return path
284
+ end
285
+ }
286
+ return nil
287
+ end
288
+
289
+ def backup
290
+ ##### オリジナルのバックアップルーチン
291
+ @opt.validate_directories(2)
292
+
293
+ log("##### backup start #####")
294
+
295
+ @written_bytes = 0
296
+ start_time = Time.now
297
+ src = @opt.src
298
+ dst = @opt.dst
299
+
300
+ # Windowsの場合
301
+ if windows?
302
+ src = expand_special_folders(src)
303
+ dst = expand_special_folders(dst)
304
+ end
305
+
306
+ # 指定されたディレクトリの整合性チェック
307
+ if same_directory?(src, dst) or sub_directory?(src, dst)
308
+ raise "cannot copy a directory, `#{src}', into itself, `#{dst}'"
309
+ end
310
+
311
+ # Ruby 1.6.xではbasename(src) == ''となるため最後の'/'を除去
312
+ src = src.sub(%r!/+$!, "") unless src == '/' #'
313
+ base = File.basename(src)
314
+ dirname = File.dirname(src)
315
+ raise RuntimeError unless FileTest.exist?(dirname + '/' + base)
316
+
317
+ # 存在するバックアップの最新を取得
318
+ latest = latest_snapshot(start_time, src, dst, base)
319
+ # 現在の日付フォルダを取得j:/to/backup1/2019/05/10/home
320
+ today = File.join(dst, datedir(start_time), base)
321
+ File.umask(0077)
322
+ FileUtils.mkpath(today) unless @opt.dry_run
323
+ if windows?
324
+ src = src.sub( /^[A-Za-z]:$/, src + "/" )
325
+ end
326
+ if latest
327
+ # バックアップがすでに存在する場合差分コピー
328
+ log("## update_snapshot #{src} #{latest}=>#{today} ##")
329
+ update_snapshot(src, latest, today)
330
+ else
331
+ # 初回は単純に再帰コピー
332
+ log("## recursive_copy #{src}=>#{today} ##")
333
+ recursive_copy(src, today)
334
+ end
335
+ unless @opt.dry_run
336
+ create_latest_symlink(dst, today)
337
+ elapsed = Time.now - start_time
338
+ log_result(src, today, elapsed)
339
+ end
340
+ log("##### backup end #####")
341
+ end
342
+
343
+ def sync
344
+ ##### バックアップフォルダの同期ルーチン(バックアップディスクを他のディスクと同じ状態にする)
345
+ @opt.validate_directories(2)
346
+
347
+ start_time = Time.now
348
+ @written_bytes = 0
349
+ src = @opt.src
350
+ dst = @opt.dst
351
+
352
+ # 制限時間まで繰り返す(指定がない場合1回で終了)
353
+ limit_time = start_time + (@opt.limit_sec)
354
+ log("##### sync start #{fmt(start_time)} => limit_time=#{fmt(limit_time)} #####")
355
+ count = 0
356
+ last_sync_complete = false
357
+ while true
358
+ count += 1
359
+ log("## sync_latest count=#{count} ##")
360
+ latest_start = Time.now
361
+ sync_result, from, to = sync_latest(src, dst)
362
+ latest_end = Time.now
363
+
364
+ log("## sync_latest result=#{sync_result} from=#{from} to=#{to} ##")
365
+ unless sync_result
366
+ # 同期結果がtrueでない場合ここで終了。ただしsync_result=falseになるのはコピー元フォルダが存在しない場合なので、
367
+ # 中途半端な結果にはならない
368
+ last_sync_complete = true
369
+ break
370
+ end
371
+
372
+ from_count, to_count = do_verify(from, to)
373
+ log("## from_count=#{from_count} to_count=#{to_count} equals=#{from_count == to_count} ##")
374
+ unless from_count == to_count
375
+ # ファイル数が同じでない場合ここで終了
376
+ last_sync_complete = false
377
+ break
378
+ end
379
+
380
+ # 次回同期にかかる時間を最終同期時間の半分と予想
381
+ next_sync = (latest_end - latest_start) / 2
382
+
383
+ cur_time = Time.now
384
+ in_limit = (cur_time + next_sync) < limit_time
385
+ log("## cur_time=#{fmt(cur_time)} + next_sync=#{next_sync} < limit_time=#{fmt(limit_time)} in_limit=#{in_limit} ## ")
386
+ unless in_limit
387
+ # 指定時間内ではない場合ここで終了(ただし最終同期は成功)
388
+ last_sync_complete = true
389
+ break
390
+ end
391
+ end
392
+
393
+ end_time = Time.now
394
+ diff = time_diff(start_time, end_time)
395
+ log("##### sync end #{fmt(end_time)} diff=#{diff} last_sync_complete=#{last_sync_complete} #####")
396
+ end
397
+
398
+ def open_verify_file
399
+ filename = File.join(@log_dir, 'verify.txt')
400
+ if FileTest.file?(filename)
401
+ File.unlink(filename)
402
+ end
403
+ File.open(filename, 'a')
404
+ end
405
+
406
+ def verify
407
+ file = @opt.open_verifyfile
408
+
409
+ start_time = Time.now
410
+ add_log("##### verify start #{fmt(start_time)} #####")
411
+
412
+ src_count, dst_count = do_verify(src, dst)
413
+
414
+ fputs(file, "#{src}: #{src_count}")
415
+ fputs(file, "#{dst}: #{dst_count}")
416
+ result = src_count == dst_count
417
+ fputs(file, "result=#{result}")
418
+
419
+ end_time = Time.now
420
+ diff = time_diff(start_time, end_time)
421
+ add_log("##### list end #{fmt(end_time)} diff=#{diff} #####")
422
+
423
+ file.close
424
+ end
425
+
426
+ def list
427
+ file = @opt.open_listfile
428
+
429
+ start_time = Time.now
430
+ log("##### list start #{fmt(start_time)} #####")
431
+
432
+ src = @opt.src
433
+ QdumpfsFind.find(@opt.logger, src) do |path|
434
+ short_path = path.sub(/^#{src}/, '.')
435
+ log("#{File.ftype(path)} #{path}")
436
+ if FileTest.file?(path)
437
+ file.puts short_path
438
+ end
439
+ end
440
+
441
+ end_time = Time.now
442
+ diff = time_diff(start_time, end_time)
443
+ log("##### list end #{fmt(end_time)} diff=#{diff} #####")
444
+
445
+ file.close
446
+ end
447
+
448
+ def expire
449
+ @opt.validate_directories(1)
450
+
451
+ start_time = Time.now
452
+ limit_time = start_time + (@opt.limit_sec)
453
+ log("##### expire start #{fmt(start_time)} => limit_time=#{fmt(limit_time)} #####")
454
+
455
+ @opt.dirs.each do |target_dir|
456
+
457
+ target_start = Time.now
458
+ expire_target_dir(target_dir)
459
+ target_end = Time.now
460
+
461
+ # 次回expireにかかる時間を最終expire時間の半分と予想
462
+ next_expire = (target_end - target_start) / 2
463
+
464
+ cur_time = Time.now
465
+ in_imit = (cur_time + next_expire) < limit_time
466
+
467
+ log("## cur_time=#{fmt(cur_time)} + next_expire=#{next_expire} < limit_time=#{fmt(limit_time)} in_limit=#{in_limit} ## ")
468
+ unless in_limit
469
+ break
470
+ end
471
+ end
472
+
473
+ log("##### expire end #####")
474
+ end
475
+
476
+ def expire_target_dir(target_dir)
477
+ target_dir = to_unix_path(target_dir)
478
+ puts "<<<<< Target dir: #{target_dir} >>>>>"
479
+
480
+ snapshots = BackupDir.scan_backup_dirs(target_dir)
481
+ @opt.detect_keep_dirs(snapshots)
482
+
483
+ # p @opt.keep_year
484
+ # p @opt.keep_month
485
+ # p @opt.keep_day
486
+
487
+ snapshots.each do |snapshot|
488
+ next if snapshot.keep
489
+ t_start = Time.now
490
+ print "Deleting #{snapshot.path} ..."
491
+
492
+ unless @opt.dry_run
493
+ #http://superuser.com/questions/19762/mass-deleting-files-in-windows/289399#289399
494
+ ##### here
495
+ #bundle exec ruby exe/qdumpfs --dry-run --keep=100Y36M30W30D --command expire f:/pc1/pdumpfs/users f:/pc1/pdumpfs/opt f:/pc1/pdumpfs/d
496
+
497
+ if windows?
498
+ # Windowsの場合
499
+ win_backup_path = to_win_path(snapshot.path)
500
+
501
+ # byenow = "byenow"
502
+ # if which(byenow)
503
+ # print " bynow"
504
+ # system("byenow -y --delete-ntapi --one-liner #{snapshot.path}")
505
+ # else
506
+ # print " pass1"
507
+ # system("del /F /S /Q #{win_backup_path} > nul")
508
+ # print " pass2"
509
+ # system("rmdir /S /Q #{win_backup_path}")
510
+ # end
511
+ system("rmdir /S /Q #{win_backup_path}")
512
+ else
513
+ # Linux/macOSの場合
514
+ system("rm -rf #{snapshot.path}")
515
+ end
516
+ end
517
+
518
+ t_end = Time.now
519
+ diff = (t_end - t_start).to_i
520
+ diff_hours = diff / 3600
521
+ puts " done[#{diff} seconds = #{diff_hours} hours]."
522
+ end
523
+
524
+ Dir.glob("#{target_dir}/[0-9][0-9][0-9][0-9]/[0-1][0-9] #{target_dir}/[0-9][0-9][0-9][0-9]").each do |dir|
525
+ if File.directory?(dir) && Dir.entries(dir).size <= 2
526
+ win_dir = to_win_path(dir)
527
+ print "Deleting #{win_dir} ..."
528
+ Dir.rmdir(win_dir) unless @opt.dry_run
529
+ puts " done."
530
+ end
531
+ end
532
+
533
+ puts "Keep dirs:"
534
+ snapshots.each do |snapshot|
535
+ puts snapshot.path if snapshot.keep
536
+ end
537
+
538
+ end
539
+ end
540
+ end