multisync 0.3.2
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.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/Gemfile +5 -0
- data/History.md +50 -0
- data/LICENSE.txt +21 -0
- data/README.md +44 -0
- data/Rakefile +2 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/multisync +5 -0
- data/lib/multisync.rb +9 -0
- data/lib/multisync/catalog.rb +40 -0
- data/lib/multisync/catalog/filter.rb +26 -0
- data/lib/multisync/catalog/list.rb +24 -0
- data/lib/multisync/cli.rb +130 -0
- data/lib/multisync/definition.rb +7 -0
- data/lib/multisync/definition/dsl.rb +61 -0
- data/lib/multisync/definition/entity.rb +116 -0
- data/lib/multisync/definition/null.rb +52 -0
- data/lib/multisync/definition/template.rb +26 -0
- data/lib/multisync/rsync_stat.rb +58 -0
- data/lib/multisync/runtime.rb +103 -0
- data/lib/multisync/version.rb +3 -0
- data/multisync.gemspec +45 -0
- data/sample/multisync.rb +39 -0
- metadata +182 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 207be5b2d2b67ce8dbf2c5b5aeb9e2fc709ddfd1646d0c6b25409b814b3d3384
|
4
|
+
data.tar.gz: 14bfcf64063a60a0a42cc04b24bd8df0843a01ec7af63950b8c0df0d72693e9a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 618fdf7e363491fcbb098e6d80689b22486ae22407faca9098def54bf6ae95395bbdbf93c6b4ba6c6e2e4b3124e10ba773c1eb534ede061cf524022a45e1f554
|
7
|
+
data.tar.gz: bc00ab0d977f2f28e83bcdee4683e3981bfcab6c3709dc34f761382d795cd96788b24cce1928647d971a89887239ae295022d80a600fb5afe1591ca65dd8c7ba
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/History.md
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
|
2
|
+
# multisync
|
3
|
+
|
4
|
+
## v0.3.1 (2018-06-01)
|
5
|
+
Changes in DSL
|
6
|
+
- removed "desc"
|
7
|
+
- "from" accepts a description option
|
8
|
+
- "to"" accepts a description option
|
9
|
+
- New: "template" and "include"
|
10
|
+
|
11
|
+
Changes in CLI
|
12
|
+
- New: timeout option
|
13
|
+
- New: quiet option
|
14
|
+
- polished output
|
15
|
+
|
16
|
+
## v0.2.4 (2017-09-23)
|
17
|
+
- Fix: check command
|
18
|
+
|
19
|
+
## v0.2.3 (2017-09-23)
|
20
|
+
- Fix: check remote path
|
21
|
+
|
22
|
+
## v0.2.2 (2017-09-23)
|
23
|
+
- replaced shell_cmd gem with mixlib-shellout
|
24
|
+
- Fix: check path for paths containing spaces
|
25
|
+
|
26
|
+
## v0.2.1 (2017-09-22)
|
27
|
+
- New: option "check_from" and "check_to" to let check host or path before sync
|
28
|
+
- New: "from" and "to" accept an optional check: true|false parameter
|
29
|
+
- Change summery output to a more compact tabular form
|
30
|
+
- Use rainbow for colorization
|
31
|
+
- Move "only_if" checks to runtime
|
32
|
+
|
33
|
+
## v0.2.0 (2016-03-22)
|
34
|
+
- New: option only_if for preflight checks, prior to sync
|
35
|
+
- Command line option -p/--print changed to --show
|
36
|
+
|
37
|
+
## v0.1.2 (2014-08-29)
|
38
|
+
- Mark default sets with an * when listing
|
39
|
+
|
40
|
+
## v0.1.1 (2014-08-28)
|
41
|
+
- Add gem dependecy 'text-highlight'
|
42
|
+
|
43
|
+
## v0.1.0 (2014-07-18)
|
44
|
+
- New: define one or more groups/syncs as default, to run when no sets have been given as args
|
45
|
+
|
46
|
+
## v0.0.2 (2014-07-17)
|
47
|
+
- Fix: do no escape option strings
|
48
|
+
|
49
|
+
## v0.0.1 (2014-07-11)
|
50
|
+
- First release
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2019 Patrick Marchi
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
[](https://badge.fury.io/rb/multisync)
|
2
|
+
|
3
|
+
# Multisync
|
4
|
+
|
5
|
+
Multisync offers a DSL to organize sets of rsync tasks. It takes advantage of templates, groups and inheritance to simplify things.
|
6
|
+
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
Add this line to your application's Gemfile:
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
gem 'multisync'
|
14
|
+
```
|
15
|
+
|
16
|
+
And then execute:
|
17
|
+
|
18
|
+
$ bundle
|
19
|
+
|
20
|
+
Or install it yourself as:
|
21
|
+
|
22
|
+
$ gem install multisync
|
23
|
+
|
24
|
+
|
25
|
+
## Usage
|
26
|
+
|
27
|
+
TODO: Write usage instructions here
|
28
|
+
|
29
|
+
|
30
|
+
## Development
|
31
|
+
|
32
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
33
|
+
|
34
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
35
|
+
|
36
|
+
|
37
|
+
## Contributing
|
38
|
+
|
39
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/multisync.
|
40
|
+
|
41
|
+
|
42
|
+
## License
|
43
|
+
|
44
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "multisync"
|
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__)
|
data/bin/setup
ADDED
data/exe/multisync
ADDED
data/lib/multisync.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
|
2
|
+
class Multisync::Catalog
|
3
|
+
autoload :List, 'multisync/catalog/list'
|
4
|
+
autoload :Filter, 'multisync/catalog/filter'
|
5
|
+
|
6
|
+
# top entity of definition
|
7
|
+
attr_reader :definition
|
8
|
+
|
9
|
+
def initialize path
|
10
|
+
@path = File.expand_path(path)
|
11
|
+
end
|
12
|
+
|
13
|
+
def definition
|
14
|
+
@_definition ||= Multisync::Definition::Entity.new(Multisync::Definition::Null.new, '__MAIN__').tap do |e|
|
15
|
+
e.instance_eval File.read(path)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def list
|
20
|
+
catalog_list = Multisync::Catalog::List.new
|
21
|
+
definition.accept(catalog_list)
|
22
|
+
catalog_list.result
|
23
|
+
end
|
24
|
+
|
25
|
+
def filter sets
|
26
|
+
catalog_filter = Multisync::Catalog::Filter.new sets
|
27
|
+
definition.accept(catalog_filter)
|
28
|
+
catalog_filter.result
|
29
|
+
end
|
30
|
+
|
31
|
+
def path
|
32
|
+
return @path if File.exist? @path
|
33
|
+
sample_path = File.expand_path('../../../sample/multisync.rb', __FILE__)
|
34
|
+
raise RuntimeError.new, "No catalog found at #{@path}. Copy sample from #{sample_path} to #{@path} and adjust to your needs."
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.default_catalog_path
|
38
|
+
'~/.multisync.rb'
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
|
2
|
+
class Multisync::Catalog::Filter
|
3
|
+
# selected sets
|
4
|
+
attr_reader :sets
|
5
|
+
|
6
|
+
# selected subjects
|
7
|
+
attr_reader :result
|
8
|
+
|
9
|
+
def initialize sets
|
10
|
+
@sets = Array(sets)
|
11
|
+
@result = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def visit subject, _level
|
15
|
+
result << subject if selected?(subject)
|
16
|
+
end
|
17
|
+
|
18
|
+
def selected? subject
|
19
|
+
# only return the leaves of the definition tree
|
20
|
+
return false unless subject.members.empty?
|
21
|
+
# no sets defined, but subject is in the default set
|
22
|
+
return true if sets.empty? && subject.default?
|
23
|
+
# subject matches any of the given sets
|
24
|
+
sets.any? {|set| /\b#{set}\b/.match subject.fullname }
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
|
2
|
+
class Multisync::Catalog::List
|
3
|
+
# result
|
4
|
+
attr_reader :result
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@result = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def visit subject, level
|
11
|
+
if level > 0
|
12
|
+
tab = ''.ljust(2*(level-1), ' ')
|
13
|
+
default = subject.default? ? ' *' : ''
|
14
|
+
name = "#{tab}#{subject.name}#{default}"
|
15
|
+
@result << [name, *description(subject)]
|
16
|
+
# puts "#{name.ljust(32, ' ')}#{description(subject)}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def description subject
|
21
|
+
desc = [subject.source_description, subject.destination_description]
|
22
|
+
desc.any?(&:empty?) ? [] : [desc.first, ['--> ', desc.last].join]
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'rainbow/ext/string'
|
3
|
+
require 'terminal-table'
|
4
|
+
|
5
|
+
class Multisync::Cli
|
6
|
+
|
7
|
+
def self.start
|
8
|
+
new.start
|
9
|
+
end
|
10
|
+
|
11
|
+
# Given sets to run or empty
|
12
|
+
attr_reader :sets
|
13
|
+
|
14
|
+
def parser
|
15
|
+
OptionParser.new do |o|
|
16
|
+
o.banner = "\nRun rsync jobs defined in the catalog file '#{options[:file]}'.\n\n"+
|
17
|
+
"Usage: #{File.basename $0} [options] [SET] [...]\n\n"+
|
18
|
+
" SET selects a section from the catalog (see option -l)\n"+
|
19
|
+
" use / as a follow:\n"+
|
20
|
+
" work/pictures to specify the sync defined in the group work and\n"+
|
21
|
+
" home/pictures to specify the sync defined in the group home\n"+
|
22
|
+
" pictures alone will select both syncs, the one in the group work\n"+
|
23
|
+
" as well as the one in the group home"
|
24
|
+
o.separator ''
|
25
|
+
o.on('-l', '--list', "List the catalog") do
|
26
|
+
options[:list] = true
|
27
|
+
end
|
28
|
+
o.on('-p', '--print', "Print the commands without executing them") do
|
29
|
+
options[:print] = true
|
30
|
+
end
|
31
|
+
o.on('-q', '--quiet', "Show only rsync summary") do
|
32
|
+
options[:quiet] = true
|
33
|
+
end
|
34
|
+
o.on('--catalog FILE', "Specify a catalog", "Default is #{options[:file]}") do |file|
|
35
|
+
options[:file] = file
|
36
|
+
end
|
37
|
+
o.on('--timeout SECS', Integer, "Timeout for rsync job", "Default is #{options[:timeout]}") do |timeout|
|
38
|
+
options[:timeout] = timeout
|
39
|
+
end
|
40
|
+
o.on('-n', '--dryrun', "Run rsync in dry-run mode") do
|
41
|
+
options[:dryrun] = true
|
42
|
+
end
|
43
|
+
o.separator ''
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def start
|
48
|
+
parser.parse!
|
49
|
+
@sets = ARGV
|
50
|
+
|
51
|
+
case
|
52
|
+
when options[:list]
|
53
|
+
list_definitions
|
54
|
+
else
|
55
|
+
run_tasks
|
56
|
+
end
|
57
|
+
puts
|
58
|
+
end
|
59
|
+
|
60
|
+
def list_definitions
|
61
|
+
puts "Catalog: #{options[:file].color(:cyan)}"
|
62
|
+
table = Terminal::Table.new(rows: catalog.list, style: table_style)
|
63
|
+
puts
|
64
|
+
puts table
|
65
|
+
end
|
66
|
+
|
67
|
+
def run_tasks
|
68
|
+
tasks.each do |task|
|
69
|
+
runtime.run task
|
70
|
+
end
|
71
|
+
return if options[:print]
|
72
|
+
table = Terminal::Table.new(headings: summary_headings, rows: summary_data, style: table_style)
|
73
|
+
puts
|
74
|
+
puts
|
75
|
+
puts table
|
76
|
+
end
|
77
|
+
|
78
|
+
def summary_headings
|
79
|
+
%w( Source Destination Files + - → ∑ ↑ ).zip(%i( left left right right right right right right )).map{|v,a| {value: v, alignment: a} }
|
80
|
+
end
|
81
|
+
|
82
|
+
def summary_data
|
83
|
+
tasks.map do |task|
|
84
|
+
result = task.result
|
85
|
+
desc = [task.source_description, "--> #{task.destination_description}"]
|
86
|
+
|
87
|
+
case result[:action]
|
88
|
+
when :run
|
89
|
+
if result[:status].success?
|
90
|
+
# successfull run
|
91
|
+
stat = Multisync::RsyncStat.new(result[:stdout]).parse
|
92
|
+
[*desc, *stat.to_a.map{|e| {value: e.color(:green), alignment: :right} } ]
|
93
|
+
else
|
94
|
+
# failed run
|
95
|
+
[*desc, { value: result[:stderr].strip.color(:red), colspan: 6 } ]
|
96
|
+
end
|
97
|
+
when :skip
|
98
|
+
# skiped sync
|
99
|
+
[*desc, { value: result[:skip_message].color(:yellow), colspan: 6 } ]
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def tasks
|
105
|
+
@_tasks ||= catalog.filter sets
|
106
|
+
end
|
107
|
+
|
108
|
+
def catalog
|
109
|
+
@_catalog ||= Multisync::Catalog.new options[:file]
|
110
|
+
end
|
111
|
+
|
112
|
+
def runtime
|
113
|
+
@_runtime ||= Multisync::Runtime.new(options)
|
114
|
+
end
|
115
|
+
|
116
|
+
def options
|
117
|
+
@_options ||= {
|
118
|
+
list: false,
|
119
|
+
print: false,
|
120
|
+
dryrun: false,
|
121
|
+
quiet: false,
|
122
|
+
file: Multisync::Catalog.default_catalog_path,
|
123
|
+
timeout: 31536000,
|
124
|
+
}
|
125
|
+
end
|
126
|
+
|
127
|
+
def table_style
|
128
|
+
{ border_top: false, border_bottom: false, border_x: '–', border_y: '', border_i: '', padding_left: 0, padding_right: 3 }
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
|
2
|
+
module Multisync::Definition::Dsl
|
3
|
+
|
4
|
+
# The DSL methods
|
5
|
+
def group name, &block
|
6
|
+
Multisync::Definition::Entity.new self, name, &block
|
7
|
+
end
|
8
|
+
|
9
|
+
def sync name, &block
|
10
|
+
Multisync::Definition::Entity.new self, name, &block
|
11
|
+
end
|
12
|
+
|
13
|
+
def template name, &block
|
14
|
+
Multisync::Definition::Template.new name, &block
|
15
|
+
end
|
16
|
+
|
17
|
+
def include name
|
18
|
+
template = Multisync::Definition::Template.lookup name
|
19
|
+
instance_eval &template.block
|
20
|
+
end
|
21
|
+
|
22
|
+
def from value, options={}
|
23
|
+
@from_value = value
|
24
|
+
# Check source's host or path before sync
|
25
|
+
@from_check = options[:check]
|
26
|
+
@from_description = options[:description]
|
27
|
+
end
|
28
|
+
|
29
|
+
def to value, options={}
|
30
|
+
@to_value = value
|
31
|
+
# Check destination's host or path before sync
|
32
|
+
@to_check = options[:check]
|
33
|
+
@to_description = options[:description]
|
34
|
+
end
|
35
|
+
|
36
|
+
def options rsync_options, mode=:append
|
37
|
+
@rsync_options_mode = mode
|
38
|
+
@rsync_options = Array(rsync_options)
|
39
|
+
end
|
40
|
+
|
41
|
+
def default
|
42
|
+
@default = true
|
43
|
+
end
|
44
|
+
|
45
|
+
# Defines a check, that should pass in order to invoke the sync
|
46
|
+
def only_if cmd, options={}
|
47
|
+
@check = { cmd: cmd, message: options.fetch(:message, cmd) }
|
48
|
+
end
|
49
|
+
|
50
|
+
# Check source's host or path before sync
|
51
|
+
# can also be set as option of "from"
|
52
|
+
def check_from flag=true
|
53
|
+
@from_check = flag
|
54
|
+
end
|
55
|
+
|
56
|
+
# Check destination's host or path before sync
|
57
|
+
# can also be set as option of "to"
|
58
|
+
def check_to flag=true
|
59
|
+
@to_check = flag
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
|
2
|
+
class Multisync::Definition::Entity
|
3
|
+
|
4
|
+
include Multisync::Definition::Dsl
|
5
|
+
|
6
|
+
# The parent of the group
|
7
|
+
attr_reader :parent
|
8
|
+
|
9
|
+
# The name of the group
|
10
|
+
attr_reader :name
|
11
|
+
|
12
|
+
# All members (groups or syncs) of this group
|
13
|
+
attr_reader :members
|
14
|
+
|
15
|
+
# Collected results after run as Hash
|
16
|
+
# {
|
17
|
+
# cmd: 'rsync --stats -v source destination',
|
18
|
+
# action: :run,
|
19
|
+
# status: #<Process::Status: pid 65416 exit 0>,
|
20
|
+
# stdout: '',
|
21
|
+
# stderr: '',
|
22
|
+
# skip_message: 'host not reachable',
|
23
|
+
# }
|
24
|
+
|
25
|
+
attr_reader :result
|
26
|
+
|
27
|
+
def initialize parent, name, &block
|
28
|
+
@members = []
|
29
|
+
@name = name.to_s
|
30
|
+
@parent = parent
|
31
|
+
parent.register self
|
32
|
+
instance_eval(&block) if block_given?
|
33
|
+
@result = {}
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_h
|
37
|
+
{
|
38
|
+
fullname: fullname,
|
39
|
+
default: default?,
|
40
|
+
source: {
|
41
|
+
path: source,
|
42
|
+
description: source_description,
|
43
|
+
check: check_source?,
|
44
|
+
},
|
45
|
+
destination: {
|
46
|
+
path: destination,
|
47
|
+
description: destination_description,
|
48
|
+
check: check_destination?,
|
49
|
+
},
|
50
|
+
checks: checks,
|
51
|
+
rsync_options: rsync_options,
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
# Make the definition visitable
|
56
|
+
def accept visitor, level=0
|
57
|
+
visitor.visit self, level
|
58
|
+
members.map do |member|
|
59
|
+
member.accept visitor, level+1
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def register member
|
64
|
+
members << member
|
65
|
+
end
|
66
|
+
|
67
|
+
# The name including all parents separated by "/"
|
68
|
+
def fullname
|
69
|
+
[parent.fullname, name].join '/'
|
70
|
+
end
|
71
|
+
|
72
|
+
# rsync source
|
73
|
+
def source
|
74
|
+
@from_value || parent.source
|
75
|
+
end
|
76
|
+
|
77
|
+
def source_description
|
78
|
+
@from_description || @from_value || parent.source_description
|
79
|
+
end
|
80
|
+
|
81
|
+
# rsync destination
|
82
|
+
def destination
|
83
|
+
@to_value || parent.destination
|
84
|
+
end
|
85
|
+
|
86
|
+
def destination_description
|
87
|
+
@to_description || @to_value || parent.destination_description
|
88
|
+
end
|
89
|
+
|
90
|
+
# rsync options
|
91
|
+
def rsync_options
|
92
|
+
opts = @rsync_options || []
|
93
|
+
return opts if @rsync_options_mode == :override
|
94
|
+
parent.rsync_options + opts
|
95
|
+
end
|
96
|
+
|
97
|
+
# Is this group/sync defined as default
|
98
|
+
def default?
|
99
|
+
@default || parent.default?
|
100
|
+
end
|
101
|
+
|
102
|
+
# All checks from parent to child
|
103
|
+
def checks
|
104
|
+
(parent.checks + [@check]).compact
|
105
|
+
end
|
106
|
+
|
107
|
+
# Should source's host or path be checked before sync?
|
108
|
+
def check_source?
|
109
|
+
@from_check.nil? ? parent.check_source? : @from_check
|
110
|
+
end
|
111
|
+
|
112
|
+
# Should destination's host or path be checked before sync?
|
113
|
+
def check_destination?
|
114
|
+
@to_check.nil? ? parent.check_destination? : @to_check
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
|
2
|
+
class Multisync::Definition::Null < Multisync::Definition::Entity
|
3
|
+
|
4
|
+
def initialize
|
5
|
+
end
|
6
|
+
|
7
|
+
def register member
|
8
|
+
end
|
9
|
+
|
10
|
+
def fullname
|
11
|
+
nil
|
12
|
+
end
|
13
|
+
|
14
|
+
def rsync_options
|
15
|
+
[]
|
16
|
+
end
|
17
|
+
|
18
|
+
# from (source) is a required option and should be set at least at root level
|
19
|
+
def source
|
20
|
+
raise "no source (from) defined"
|
21
|
+
end
|
22
|
+
|
23
|
+
def source_description
|
24
|
+
''
|
25
|
+
end
|
26
|
+
|
27
|
+
# to (destination) is a required option and should be set at least at root level
|
28
|
+
def destination
|
29
|
+
raise "no destination (to) defined"
|
30
|
+
end
|
31
|
+
|
32
|
+
def destination_description
|
33
|
+
''
|
34
|
+
end
|
35
|
+
|
36
|
+
def default?
|
37
|
+
false
|
38
|
+
end
|
39
|
+
|
40
|
+
def checks
|
41
|
+
[]
|
42
|
+
end
|
43
|
+
|
44
|
+
def check_source?
|
45
|
+
false
|
46
|
+
end
|
47
|
+
|
48
|
+
def check_destination?
|
49
|
+
false
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
@@ -0,0 +1,26 @@
|
|
1
|
+
|
2
|
+
class Multisync::Definition::Template
|
3
|
+
include Multisync::Definition::Dsl
|
4
|
+
|
5
|
+
@registered = []
|
6
|
+
|
7
|
+
def self.register instance
|
8
|
+
@registered << instance
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.lookup name
|
12
|
+
@registered.find {|instance| instance.name == name }
|
13
|
+
end
|
14
|
+
|
15
|
+
# The name of the template
|
16
|
+
attr_reader :name
|
17
|
+
|
18
|
+
# The block the template holds
|
19
|
+
attr_reader :block
|
20
|
+
|
21
|
+
def initialize name, &block
|
22
|
+
@name = name
|
23
|
+
self.class.register self
|
24
|
+
@block = block
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
|
2
|
+
require 'filesize'
|
3
|
+
|
4
|
+
class Multisync::RsyncStat
|
5
|
+
|
6
|
+
def initialize output
|
7
|
+
@output = output
|
8
|
+
end
|
9
|
+
|
10
|
+
# Build an internal hash with normalized stats
|
11
|
+
def parse
|
12
|
+
@stats = definitions.each_with_object({}) do |definition, stats|
|
13
|
+
value = scan[definition[:match]]
|
14
|
+
stats[definition[:key]] = value ? (definition[:coerce] ? definition[:coerce].call(value) : value) : definition[:default]
|
15
|
+
end
|
16
|
+
self
|
17
|
+
end
|
18
|
+
|
19
|
+
# Scan output and return a hash
|
20
|
+
# {
|
21
|
+
# "Number of files" => "35,648",
|
22
|
+
# "Number of created files" => "0",
|
23
|
+
# "Number of deleted files" => "0",
|
24
|
+
# "Number of regular files transferred"=>"0",
|
25
|
+
# ...
|
26
|
+
# }
|
27
|
+
def scan
|
28
|
+
@scan ||= @output.scan(/(#{definitions.map{|d| d[:match] }.join('|')}):\s+([,0-9]+)/).each_with_object({}) {|(k,v), o| o[k] = v }
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_a
|
32
|
+
[
|
33
|
+
@stats[:files],
|
34
|
+
@stats[:created],
|
35
|
+
@stats[:deleted],
|
36
|
+
@stats[:transferred],
|
37
|
+
@stats[:file_size],
|
38
|
+
@stats[:transferred_size],
|
39
|
+
]
|
40
|
+
end
|
41
|
+
|
42
|
+
def method_missing name
|
43
|
+
key = name.to_sym
|
44
|
+
return @stats[key] if @stats.keys.include? key
|
45
|
+
super
|
46
|
+
end
|
47
|
+
|
48
|
+
def definitions
|
49
|
+
[
|
50
|
+
{ key: :files, match: 'Number of files', coerce: ->(x) { x.gsub(',',"'") }, default: '0' },
|
51
|
+
{ key: :created, match: 'Number of created files', coerce: ->(x) { x.gsub(',',"'") }, default: '0' },
|
52
|
+
{ key: :deleted, match: 'Number of deleted files', coerce: ->(x) { x.gsub(',',"'") }, default: '0' },
|
53
|
+
{ key: :transferred, match: 'Number of regular files transferred', coerce: ->(x) { x.gsub(',',"'") }, default: '0' },
|
54
|
+
{ key: :file_size, match: 'Total file size', coerce: ->(x) { Filesize.new(x.gsub(',','').to_i).pretty }, default: '0 B' },
|
55
|
+
{ key: :transferred_size, match: 'Total transferred file size', coerce: ->(x) { Filesize.new(x.gsub(',','').to_i).pretty }, default: '0 B' },
|
56
|
+
]
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
|
2
|
+
require 'mixlib/shellout'
|
3
|
+
|
4
|
+
class Multisync::Runtime
|
5
|
+
|
6
|
+
# Runtime options
|
7
|
+
# dryrun: true|false
|
8
|
+
# show: true|false
|
9
|
+
attr_reader :options
|
10
|
+
|
11
|
+
def initialize options
|
12
|
+
@options = options
|
13
|
+
end
|
14
|
+
|
15
|
+
def dryrun?
|
16
|
+
options[:dryrun]
|
17
|
+
end
|
18
|
+
|
19
|
+
def show_only?
|
20
|
+
options[:print]
|
21
|
+
end
|
22
|
+
|
23
|
+
def quiet?
|
24
|
+
options[:quiet]
|
25
|
+
end
|
26
|
+
|
27
|
+
def timeout
|
28
|
+
options[:timeout]
|
29
|
+
end
|
30
|
+
|
31
|
+
def run sync
|
32
|
+
rsync_options = sync.rsync_options.dup
|
33
|
+
rsync_options.unshift '--stats'
|
34
|
+
rsync_options.unshift '--verbose' unless quiet?
|
35
|
+
rsync_options.unshift '--dry-run' if dryrun?
|
36
|
+
|
37
|
+
# escape path by hand, shellescape escapes also ~, but we want to keep its
|
38
|
+
# special meaning for home, instead of passing it as literal char
|
39
|
+
source, destination = [sync.source, sync.destination].map {|path| path.gsub(/\s+/, '\\ ') }
|
40
|
+
cmd = "rsync #{rsync_options.join(' ')} #{source} #{destination}"
|
41
|
+
rsync = Mixlib::ShellOut.new(cmd, live_stdout: $stdout, live_stderr: $stderr, timeout: timeout)
|
42
|
+
sync.result[:cmd] = rsync.command
|
43
|
+
|
44
|
+
puts
|
45
|
+
puts [sync.source_description, sync.destination_description].join(' --> ').color(:cyan)
|
46
|
+
|
47
|
+
# Perform all only_if checks, from top to bottom
|
48
|
+
sync.checks.each do |check|
|
49
|
+
next unless Mixlib::ShellOut.new(check[:cmd]).run_command.error?
|
50
|
+
|
51
|
+
puts check[:cmd] + ' (failed)'
|
52
|
+
puts "Skip: ".color(:yellow) + rsync.command
|
53
|
+
sync.result[:action] = :skip
|
54
|
+
sync.result[:skip_message] = check[:message]
|
55
|
+
return
|
56
|
+
end
|
57
|
+
|
58
|
+
# source check
|
59
|
+
if sync.check_source? && ! check_path(sync.source, :source)
|
60
|
+
puts "Source #{sync.source} is not accessible"
|
61
|
+
puts "Skip: ".color(:yellow) + rsync.command
|
62
|
+
sync.result[:action] = :skip
|
63
|
+
sync.result[:skip_message] = "Source is not accessible"
|
64
|
+
return
|
65
|
+
end
|
66
|
+
|
67
|
+
# target check
|
68
|
+
if sync.check_destination? && ! check_path(sync.destination, :destination)
|
69
|
+
puts "Destination #{sync.destination} is not accessible"
|
70
|
+
puts "Skip: ".color(:yellow) + rsync.command
|
71
|
+
sync.result[:action] = :skip
|
72
|
+
sync.result[:skip_message] = "Destination is not accessible"
|
73
|
+
return
|
74
|
+
end
|
75
|
+
|
76
|
+
if show_only?
|
77
|
+
puts rsync.command
|
78
|
+
else
|
79
|
+
sync.result[:action] = :run
|
80
|
+
puts rsync.command if dryrun?
|
81
|
+
rsync.run_command
|
82
|
+
sync.result[:status] = rsync.status
|
83
|
+
sync.result[:stdout] = rsync.stdout
|
84
|
+
sync.result[:stderr] = rsync.stderr
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# checks a path
|
89
|
+
# if path includes a host, the reachability of the host will be checked
|
90
|
+
# the existence of the remote path will not be checked
|
91
|
+
# if path is a local source path, its existence will be checked
|
92
|
+
# if path is a local destination path, the existence of the parent will be checked
|
93
|
+
def check_path path, type = :source
|
94
|
+
if path.include? ':'
|
95
|
+
host = path.split(':').first.split('@').last
|
96
|
+
Mixlib::ShellOut.new("ping -o -t 1 #{host}").run_command.status.success?
|
97
|
+
else
|
98
|
+
abs_path = File.expand_path path
|
99
|
+
abs_path = File.dirname abs_path if type == :destination
|
100
|
+
File.exist? abs_path
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
data/multisync.gemspec
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
lib = File.expand_path("../lib", __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require "multisync/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "multisync"
|
7
|
+
spec.version = Multisync::VERSION
|
8
|
+
spec.authors = ["Patrick Marchi"]
|
9
|
+
spec.email = ["mail@patrickmarchi.ch"]
|
10
|
+
|
11
|
+
spec.summary = %q{Manage rsync configurations in sets of rules.}
|
12
|
+
spec.description = %q{Manage rsync configurations organized in groups with inherited options.}
|
13
|
+
spec.homepage = ""
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
17
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
18
|
+
if spec.respond_to?(:metadata)
|
19
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
20
|
+
|
21
|
+
# spec.metadata["homepage_uri"] = spec.homepage
|
22
|
+
# spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
|
23
|
+
# spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
|
24
|
+
else
|
25
|
+
raise "RubyGems 2.0 or newer is required to protect against " \
|
26
|
+
"public gem pushes."
|
27
|
+
end
|
28
|
+
|
29
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
30
|
+
f.match(%r{^(test|spec|features)/})
|
31
|
+
end
|
32
|
+
spec.bindir = "exe"
|
33
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
34
|
+
spec.require_paths = ["lib"]
|
35
|
+
|
36
|
+
spec.add_dependency "mixlib-shellout"
|
37
|
+
spec.add_dependency "filesize"
|
38
|
+
spec.add_dependency "rainbow"
|
39
|
+
spec.add_dependency "terminal-table"
|
40
|
+
|
41
|
+
spec.add_development_dependency "bundler", "~> 1.16"
|
42
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
43
|
+
spec.add_development_dependency "rspec"
|
44
|
+
spec.add_development_dependency "pry"
|
45
|
+
end
|
data/sample/multisync.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
options %w( --archive --delete --delete-excluded --exclude=.DS_Store )
|
2
|
+
|
3
|
+
group :home do
|
4
|
+
group :cloud do
|
5
|
+
to "/Backup/Cloud"
|
6
|
+
|
7
|
+
sync :dropbox do
|
8
|
+
desc "Dropbox"
|
9
|
+
from "~/Dropbox"
|
10
|
+
options %q(--exclude='.dropbox.cache')
|
11
|
+
end
|
12
|
+
|
13
|
+
sync :copy do
|
14
|
+
desc "Copy"
|
15
|
+
from "~/Copy"
|
16
|
+
options %w(--archive --delete --exclude='.copy.cache'), :override
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
sync :pictures do
|
21
|
+
desc "Pictures"
|
22
|
+
from "~/Pictures/Private"
|
23
|
+
to "/Backup/Home"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
group :work do
|
28
|
+
to "/Backup/Work"
|
29
|
+
|
30
|
+
sync :pictures do
|
31
|
+
desc "Pictures"
|
32
|
+
from "~/Pictures/Work"
|
33
|
+
end
|
34
|
+
|
35
|
+
sync :doc do
|
36
|
+
desc "Documentation"
|
37
|
+
from "~/Work/doc"
|
38
|
+
end
|
39
|
+
end
|
metadata
ADDED
@@ -0,0 +1,182 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: multisync
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.3.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Patrick Marchi
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-07-23 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: mixlib-shellout
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: filesize
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rainbow
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: terminal-table
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: bundler
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.16'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.16'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '10.0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '10.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rspec
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: pry
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description: Manage rsync configurations organized in groups with inherited options.
|
126
|
+
email:
|
127
|
+
- mail@patrickmarchi.ch
|
128
|
+
executables:
|
129
|
+
- multisync
|
130
|
+
extensions: []
|
131
|
+
extra_rdoc_files: []
|
132
|
+
files:
|
133
|
+
- ".gitignore"
|
134
|
+
- ".rspec"
|
135
|
+
- Gemfile
|
136
|
+
- History.md
|
137
|
+
- LICENSE.txt
|
138
|
+
- README.md
|
139
|
+
- Rakefile
|
140
|
+
- bin/console
|
141
|
+
- bin/setup
|
142
|
+
- exe/multisync
|
143
|
+
- lib/multisync.rb
|
144
|
+
- lib/multisync/catalog.rb
|
145
|
+
- lib/multisync/catalog/filter.rb
|
146
|
+
- lib/multisync/catalog/list.rb
|
147
|
+
- lib/multisync/cli.rb
|
148
|
+
- lib/multisync/definition.rb
|
149
|
+
- lib/multisync/definition/dsl.rb
|
150
|
+
- lib/multisync/definition/entity.rb
|
151
|
+
- lib/multisync/definition/null.rb
|
152
|
+
- lib/multisync/definition/template.rb
|
153
|
+
- lib/multisync/rsync_stat.rb
|
154
|
+
- lib/multisync/runtime.rb
|
155
|
+
- lib/multisync/version.rb
|
156
|
+
- multisync.gemspec
|
157
|
+
- sample/multisync.rb
|
158
|
+
homepage: ''
|
159
|
+
licenses:
|
160
|
+
- MIT
|
161
|
+
metadata:
|
162
|
+
allowed_push_host: https://rubygems.org
|
163
|
+
post_install_message:
|
164
|
+
rdoc_options: []
|
165
|
+
require_paths:
|
166
|
+
- lib
|
167
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
168
|
+
requirements:
|
169
|
+
- - ">="
|
170
|
+
- !ruby/object:Gem::Version
|
171
|
+
version: '0'
|
172
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
173
|
+
requirements:
|
174
|
+
- - ">="
|
175
|
+
- !ruby/object:Gem::Version
|
176
|
+
version: '0'
|
177
|
+
requirements: []
|
178
|
+
rubygems_version: 3.0.1
|
179
|
+
signing_key:
|
180
|
+
specification_version: 4
|
181
|
+
summary: Manage rsync configurations in sets of rules.
|
182
|
+
test_files: []
|