disloku 0.1.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.
- checksums.yaml +7 -0
- data/bin/disloku +4 -0
- data/lib/disloku/BaseTask.rb +39 -0
- data/lib/disloku/ChangeSet.rb +14 -0
- data/lib/disloku/ChangeSetProvider.rb +13 -0
- data/lib/disloku/Commit.rb +7 -0
- data/lib/disloku/Config.rb +52 -0
- data/lib/disloku/Connection.rb +31 -0
- data/lib/disloku/ConnectionStore.rb +16 -0
- data/lib/disloku/Disloku.rb +103 -0
- data/lib/disloku/DislokuError.rb +5 -0
- data/lib/disloku/FileChange.rb +17 -0
- data/lib/disloku/Log.rb +35 -0
- data/lib/disloku/Mapping.rb +81 -0
- data/lib/disloku/MappingStore.rb +16 -0
- data/lib/disloku/NamedConfigStore.rb +39 -0
- data/lib/disloku/Options.rb +38 -0
- data/lib/disloku/Repository.rb +29 -0
- data/lib/disloku/SessionManager.rb +38 -0
- data/lib/disloku/SysCmd.rb +15 -0
- data/lib/disloku/SysCmdResult.rb +12 -0
- data/lib/disloku/Target.rb +35 -0
- data/lib/disloku/git/ChangeSetProvider.rb +48 -0
- data/lib/disloku/git/Repository.rb +31 -0
- data/lib/disloku/svn/ChangeSetProvider.rb +41 -0
- data/lib/disloku/svn/Repository.rb +22 -0
- data/lib/disloku/tasks/FolderTask.rb +79 -0
- data/lib/disloku/tasks/NetSftpTask.rb +95 -0
- data/lib/disloku/tasks/PsFtpTask.rb +58 -0
- data/lib/disloku/util/File.rb +47 -0
- data/lib/disloku/util/Hash.rb +12 -0
- data/lib/disloku.rb +32 -0
- metadata +75 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 114b22aa9d4750cc34ec61df2eaa35db116d2019
|
4
|
+
data.tar.gz: e3ca011d1d3b297c073dec2a226fce5d290c6ef4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 405cdd36e1552d1504d660cb604ce12d3bb3c5e05507b593c710a4bd761b24f031b12d98f02cb3e81e06d5d85353457212deed90ad235ad5bd21acfd5cd7ba1a
|
7
|
+
data.tar.gz: a098da49f71bbec30fdc13b55572e6f7d19a640cf52c2d9408d6861b7fd5518399796de84dbb3707b35a719d7389f88982674ae459283f702fc1064c4de11a1e
|
data/bin/disloku
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
|
2
|
+
module Disloku
|
3
|
+
class BaseTask
|
4
|
+
attr_accessor :changesets
|
5
|
+
|
6
|
+
def initialize()
|
7
|
+
@result = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def getInputParam(input, name, klass)
|
11
|
+
if (!input.has_key?(name))
|
12
|
+
raise ArgumentError.new("Missing input argument '#{name}' of type '#{klass}'")
|
13
|
+
end
|
14
|
+
if (!input[name].kind_of?(klass))
|
15
|
+
raise ArgumentError.new("Input argument '#{name}' is not of type '#{klass}'")
|
16
|
+
end
|
17
|
+
|
18
|
+
return input[name]
|
19
|
+
end
|
20
|
+
|
21
|
+
def execute()
|
22
|
+
Log.instance.info("running task '#{self.class}'")
|
23
|
+
beforeExecute()
|
24
|
+
executeTask()
|
25
|
+
afterExecute()
|
26
|
+
return @result
|
27
|
+
end
|
28
|
+
|
29
|
+
def beforeExecute()
|
30
|
+
end
|
31
|
+
|
32
|
+
def executeTask()
|
33
|
+
raise NotImplementedError.new()
|
34
|
+
end
|
35
|
+
|
36
|
+
def afterExecute()
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require('YAML')
|
2
|
+
require_relative('util/Hash')
|
3
|
+
|
4
|
+
module Disloku
|
5
|
+
class Config
|
6
|
+
attr_accessor :yaml
|
7
|
+
|
8
|
+
def initialize(config, isFile = true)
|
9
|
+
if (isFile)
|
10
|
+
@yaml = YAML.load_file(config)
|
11
|
+
else
|
12
|
+
@yaml = config
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def merge(base)
|
17
|
+
@yaml = base.yaml.recursive_merge(@yaml)
|
18
|
+
end
|
19
|
+
|
20
|
+
def has?(key)
|
21
|
+
return self[key] != nil
|
22
|
+
end
|
23
|
+
|
24
|
+
def get(keys)
|
25
|
+
if (keys.empty?())
|
26
|
+
return self
|
27
|
+
end
|
28
|
+
current = keys.shift()
|
29
|
+
return @yaml.has_key?(current) ? Config.new(@yaml[current], false).get(keys) : nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def [](key)
|
33
|
+
return self.get(key.split('.'))
|
34
|
+
end
|
35
|
+
|
36
|
+
def value()
|
37
|
+
if (@yaml.kind_of?(Array))
|
38
|
+
return @yaml.map() { |item| Config.new(item, false) }
|
39
|
+
end
|
40
|
+
return @yaml
|
41
|
+
end
|
42
|
+
|
43
|
+
def to_s()
|
44
|
+
return value().to_s()
|
45
|
+
end
|
46
|
+
|
47
|
+
def to_yaml()
|
48
|
+
return @yaml.to_yaml()
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require('net/sftp')
|
2
|
+
require('digest/sha1')
|
3
|
+
|
4
|
+
module Disloku
|
5
|
+
class Connection
|
6
|
+
attr_accessor :hash, :host, :user, :options
|
7
|
+
|
8
|
+
def initialize(config)
|
9
|
+
@host = config["host"].value()
|
10
|
+
@user = config["user"].value() if !config["user"].nil?
|
11
|
+
@options = {}
|
12
|
+
addOption(config, :password)
|
13
|
+
addOption(config, :port)
|
14
|
+
addOption(config, :keys, true)
|
15
|
+
|
16
|
+
@hash = Digest::SHA1.hexdigest([@host, @user, @options].join())
|
17
|
+
end
|
18
|
+
|
19
|
+
def addOption(config, key, unwrap = false)
|
20
|
+
value = config[key.to_s()]
|
21
|
+
if (!value.nil?)
|
22
|
+
if (unwrap)
|
23
|
+
@options[key] = value.value().map() { |e| e.value() }
|
24
|
+
else
|
25
|
+
@options[key] = value.value()
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require_relative('NamedConfigStore')
|
2
|
+
require_relative('Connection')
|
3
|
+
|
4
|
+
module Disloku
|
5
|
+
class ConnectionStore < NamedConfigStore
|
6
|
+
|
7
|
+
def initialize(config = nil)
|
8
|
+
super(config)
|
9
|
+
end
|
10
|
+
|
11
|
+
def transformConfig(configObject)
|
12
|
+
return Connection.new(configObject)
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
require_relative('DislokuError')
|
2
|
+
require_relative('Config')
|
3
|
+
require_relative('Options')
|
4
|
+
require_relative('MappingStore')
|
5
|
+
require_relative('ConnectionStore')
|
6
|
+
require_relative('Target')
|
7
|
+
require_relative('git/Repository')
|
8
|
+
require_relative('tasks/FolderTask')
|
9
|
+
require_relative('tasks/NetSftpTask')
|
10
|
+
|
11
|
+
module Disloku
|
12
|
+
class Disloku
|
13
|
+
attr_accessor :repository, :config
|
14
|
+
|
15
|
+
def initialize(cliOptions)
|
16
|
+
@repository = Git::Repository.new(cliOptions[:dir])
|
17
|
+
@config = loadConfiguration()
|
18
|
+
@options = Options.new(@config["options"], cliOptions)
|
19
|
+
@mappingStore = MappingStore.new(@config["mappings"])
|
20
|
+
@connectionStore = ConnectionStore.new(@config["connections"])
|
21
|
+
end
|
22
|
+
|
23
|
+
def loadConfiguration()
|
24
|
+
repoConfig = File.join(repository.root, 'disloku.config')
|
25
|
+
if (!File.exists?(repoConfig))
|
26
|
+
raise DislokuError.new("There is no disloku.config file in #{repository.root}")
|
27
|
+
end
|
28
|
+
|
29
|
+
config = Config.new(repoConfig)
|
30
|
+
|
31
|
+
userHome = File.expand_path("~")
|
32
|
+
userConfig = File.join(userHome, ".disloku.config")
|
33
|
+
if (File.exists?(userConfig))
|
34
|
+
base = Config.new(userConfig)
|
35
|
+
config.merge(base)
|
36
|
+
end
|
37
|
+
|
38
|
+
return config
|
39
|
+
end
|
40
|
+
|
41
|
+
def buildPackage(from, to)
|
42
|
+
changeset = @repository.getChangeSet(from, to)
|
43
|
+
|
44
|
+
folderInput = {
|
45
|
+
:options => @options,
|
46
|
+
:allowOverride => false,
|
47
|
+
:changesets => [changeset],
|
48
|
+
:target => nil,
|
49
|
+
}
|
50
|
+
|
51
|
+
resolveTargets([@options.target]).each() do |t|
|
52
|
+
folderInput[:target] = t
|
53
|
+
|
54
|
+
result = Tasks::FolderTask.new(folderInput).execute()
|
55
|
+
p(result)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def deployPackage(from, to)
|
60
|
+
changeset = @repository.getChangeSet(from, to)
|
61
|
+
|
62
|
+
folderInput = {
|
63
|
+
:options => @options,
|
64
|
+
:allowOverride => false,
|
65
|
+
:changesets => [changeset],
|
66
|
+
:target => nil,
|
67
|
+
}
|
68
|
+
|
69
|
+
resolveTargets([@options.target]).each() do |t|
|
70
|
+
folderInput[:target] = t
|
71
|
+
|
72
|
+
result = Tasks::FolderTask.new(folderInput).execute()
|
73
|
+
|
74
|
+
sftpInput = result.merge({
|
75
|
+
:repository => @repository,
|
76
|
+
:options => @options,
|
77
|
+
:target => t,
|
78
|
+
})
|
79
|
+
|
80
|
+
result = Tasks::NetSftpTask.new(sftpInput).execute()
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def resolveTargets(targets)
|
85
|
+
actualTargets = []
|
86
|
+
|
87
|
+
while (targets.count > 0)
|
88
|
+
current = targets.shift()
|
89
|
+
targetConfig = @config["targets"][current]
|
90
|
+
if (targetConfig != nil)
|
91
|
+
if (targetConfig["targets"] != nil)
|
92
|
+
targetConfig["targets"].value().each() { |x| targets.push(x.value()) }
|
93
|
+
next
|
94
|
+
end
|
95
|
+
|
96
|
+
actualTargets.push(Target.new(current, targetConfig, @mappingStore, @connectionStore))
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
return actualTargets
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require_relative('util/File')
|
2
|
+
|
3
|
+
module Disloku
|
4
|
+
class FileChange
|
5
|
+
attr_accessor :repository, :changeType
|
6
|
+
|
7
|
+
def initialize(repository, fullPath, changeType)
|
8
|
+
@repository = repository
|
9
|
+
@fullPath = fullPath
|
10
|
+
@changeType = changeType
|
11
|
+
end
|
12
|
+
|
13
|
+
def getFile(target)
|
14
|
+
return Util::File.new(@fullPath, @repository.root, target, self)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/disloku/Log.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
require('Singleton')
|
2
|
+
require('Logger')
|
3
|
+
|
4
|
+
module Disloku
|
5
|
+
class Log
|
6
|
+
include Singleton
|
7
|
+
|
8
|
+
def initialize()
|
9
|
+
@logger = Logger.new(STDOUT)
|
10
|
+
@logger.formatter = proc { |severity, datetime, progname, msg|
|
11
|
+
"#{msg}\n"
|
12
|
+
}
|
13
|
+
end
|
14
|
+
|
15
|
+
def debug(message)
|
16
|
+
@logger.debug(message)
|
17
|
+
end
|
18
|
+
|
19
|
+
def info(message)
|
20
|
+
@logger.debug(message)
|
21
|
+
end
|
22
|
+
|
23
|
+
def warn(message)
|
24
|
+
@logger.debug(message)
|
25
|
+
end
|
26
|
+
|
27
|
+
def error(message)
|
28
|
+
@logger.debug(message)
|
29
|
+
end
|
30
|
+
|
31
|
+
def fatal(message)
|
32
|
+
@logger.debug(message)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require_relative('util/Hash')
|
2
|
+
require_relative('util/File')
|
3
|
+
|
4
|
+
module Disloku
|
5
|
+
class Mapping
|
6
|
+
|
7
|
+
def initialize(config, mappingStore = nil, allowDefault = true)
|
8
|
+
@mapping = {}
|
9
|
+
|
10
|
+
mappingConfig = config["mapping"]
|
11
|
+
if (!mappingConfig.nil?)
|
12
|
+
mappingConfig.value().each() do |m|
|
13
|
+
node = @mapping
|
14
|
+
src = m["src"].value()
|
15
|
+
|
16
|
+
if (src.kind_of?(Symbol))
|
17
|
+
segments = [src]
|
18
|
+
else
|
19
|
+
segments = src.split(/#{Util::SPLIT_EXP}/)
|
20
|
+
|
21
|
+
segments[0..-2].each() do |segment|
|
22
|
+
if (!node.has_key?(segment))
|
23
|
+
node[segment] = {}
|
24
|
+
end
|
25
|
+
node = node[segment]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
if (!m["block"].nil? && m["block"].value())
|
30
|
+
node[segments[-1]] = :block
|
31
|
+
else
|
32
|
+
dst = m["dst"].value()
|
33
|
+
if (dst.kind_of?(Symbol))
|
34
|
+
node[segments[-1]] = dst
|
35
|
+
else
|
36
|
+
node[segments[-1]] = m["dst"].value().split(/#{Util::SPLIT_EXP}/)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
elsif (allowDefault)
|
41
|
+
@mapping[:any] = :keep
|
42
|
+
end
|
43
|
+
|
44
|
+
baseMapping = config["baseMapping"].nil? ? nil : config["baseMapping"].value()
|
45
|
+
|
46
|
+
if (!baseMapping.nil?)
|
47
|
+
if (mappingStore.nil?)
|
48
|
+
raise ArgumentError.new("mapping has a base but no mapping manager was passed")
|
49
|
+
else
|
50
|
+
@mapping = mappingStore.get(baseMapping).getTree().recursive_merge(@mapping)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def getTree()
|
56
|
+
return @mapping
|
57
|
+
end
|
58
|
+
|
59
|
+
def mapPath(pathSegments)
|
60
|
+
node = @mapping
|
61
|
+
for i in 0..pathSegments.count
|
62
|
+
if (node.has_key?(pathSegments[i]))
|
63
|
+
node = node[pathSegments[i]]
|
64
|
+
elsif (node.has_key?(:any))
|
65
|
+
node = node[:any]
|
66
|
+
else
|
67
|
+
return nil
|
68
|
+
end
|
69
|
+
|
70
|
+
if (node == :block)
|
71
|
+
return nil
|
72
|
+
elsif (node == :keep)
|
73
|
+
return pathSegments
|
74
|
+
elsif (node.kind_of?(Array))
|
75
|
+
return Array.new(node).concat(pathSegments[(i + 1)..-1])
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require_relative('NamedConfigStore')
|
2
|
+
require_relative('Mapping')
|
3
|
+
|
4
|
+
module Disloku
|
5
|
+
class MappingStore < NamedConfigStore
|
6
|
+
|
7
|
+
def initialize(config = nil)
|
8
|
+
super(config)
|
9
|
+
end
|
10
|
+
|
11
|
+
def transformConfig(configObject)
|
12
|
+
return Mapping.new(configObject, self, false)
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require_relative('DislokuError')
|
2
|
+
require_relative('Connection')
|
3
|
+
|
4
|
+
module Disloku
|
5
|
+
class NamedConfigStore
|
6
|
+
|
7
|
+
def initialize(config = nil)
|
8
|
+
@store = {}
|
9
|
+
if (!config.nil?)
|
10
|
+
load(config)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def get(name)
|
15
|
+
if (@store.has_key?(name))
|
16
|
+
return @store[name]
|
17
|
+
else
|
18
|
+
raise DislokuError.new("There is no stored object with the name '#{name}' in this store")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def add(name, configObject)
|
23
|
+
@store[name] = transformConfig(configObject)
|
24
|
+
end
|
25
|
+
|
26
|
+
def load(config)
|
27
|
+
if (!config.nil?)
|
28
|
+
config.value().each_key() do |key|
|
29
|
+
add(key, config[key])
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def transformConfig(configObject)
|
35
|
+
return configObject
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require_relative('Config')
|
2
|
+
require('fileutils')
|
3
|
+
require('tmpdir')
|
4
|
+
|
5
|
+
module Disloku
|
6
|
+
class Options
|
7
|
+
|
8
|
+
def initialize(config, cliOptions)
|
9
|
+
@options = {
|
10
|
+
:ignoreDeleteErrors => false,
|
11
|
+
:createDeletesFile => false,
|
12
|
+
:target => "default",
|
13
|
+
:packageDir => :temp,
|
14
|
+
}
|
15
|
+
|
16
|
+
@options.each_key() do |key|
|
17
|
+
if (cliOptions.has_key?(key.to_s()))
|
18
|
+
@options[key] = cliOptions[key.to_s()]
|
19
|
+
elsif (config[key.to_s()] != nil)
|
20
|
+
@options[key] = config[key.to_s()].value()
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
if (@options[:packageDir] == :temp)
|
25
|
+
puts("creating tempdir")
|
26
|
+
@options[:packageDir] = Dir.mktmpdir("disloku")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def method_missing(name, *args, &block)
|
31
|
+
if (!@options.has_key?(name))
|
32
|
+
raise ArgumentError.new("There's no option '#{name}' here")
|
33
|
+
end
|
34
|
+
|
35
|
+
return @options[name]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require_relative('util/File')
|
2
|
+
|
3
|
+
module Disloku
|
4
|
+
class Repository
|
5
|
+
attr_accessor :location, :root
|
6
|
+
|
7
|
+
def initialize(location)
|
8
|
+
@location = location
|
9
|
+
@root = getRepositoryRoot()
|
10
|
+
@gitDir = File.join(@root, ".git")
|
11
|
+
@provider = getProvider()
|
12
|
+
end
|
13
|
+
|
14
|
+
def getRepositoryRoot()
|
15
|
+
raise NotImplementedError.new()
|
16
|
+
end
|
17
|
+
|
18
|
+
def getBranchName()
|
19
|
+
raise NotImplementedError.new()
|
20
|
+
end
|
21
|
+
|
22
|
+
def getProvider()
|
23
|
+
end
|
24
|
+
|
25
|
+
def getChangeSet(from = nil, to = nil)
|
26
|
+
return @provider.getChangeSet(from, to)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require_relative('Log')
|
2
|
+
require('net/sftp')
|
3
|
+
require('digest/sha1')
|
4
|
+
|
5
|
+
module Disloku
|
6
|
+
class SessionManager
|
7
|
+
|
8
|
+
include Singleton
|
9
|
+
|
10
|
+
def initialize()
|
11
|
+
@sessions = {}
|
12
|
+
|
13
|
+
at_exit { shutdown!() }
|
14
|
+
end
|
15
|
+
|
16
|
+
def get(connection, &block)
|
17
|
+
connection = getConnection(connection)
|
18
|
+
block.call(connection)
|
19
|
+
end
|
20
|
+
|
21
|
+
def getConnection(connection)
|
22
|
+
if (!@sessions.has_key?(connection.hash))
|
23
|
+
Log.instance.info("creating new session #{connection.user}@#{connection.host} -> #{connection.hash}")
|
24
|
+
session = Net::SSH.start(connection.host, connection.user, connection.options)
|
25
|
+
sftp = Net::SFTP::Session.new(session).connect!
|
26
|
+
@sessions[connection.hash] = { :sftp => sftp, :ssh => session }
|
27
|
+
end
|
28
|
+
|
29
|
+
return @sessions[connection.hash][:sftp]
|
30
|
+
end
|
31
|
+
|
32
|
+
def shutdown!()
|
33
|
+
Log.instance.info("closing all open sessions")
|
34
|
+
@sessions.each {|key, value| value[:ssh].close() }
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require_relative('Log');
|
2
|
+
require_relative('SysCmdResult');
|
3
|
+
|
4
|
+
module Disloku
|
5
|
+
class SysCmd
|
6
|
+
def initialize(cmd)
|
7
|
+
@cmd = cmd
|
8
|
+
end
|
9
|
+
|
10
|
+
def execute()
|
11
|
+
Log.instance.info("executing '#{@cmd}'")
|
12
|
+
return SysCmdResult.new(%x(#{@cmd}), $?)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require_relative('Mapping')
|
2
|
+
require_relative('Connection')
|
3
|
+
require_relative('util/File')
|
4
|
+
|
5
|
+
module Disloku
|
6
|
+
class Target
|
7
|
+
attr_accessor :name, :connection
|
8
|
+
|
9
|
+
def initialize(name, targetConfig, mappingStore, connectionStore)
|
10
|
+
@name = name
|
11
|
+
@config = targetConfig
|
12
|
+
|
13
|
+
if (@config["connection"].value().kind_of?(String))
|
14
|
+
@connection = connectionStore.get(@config["connection"].value())
|
15
|
+
else
|
16
|
+
@connection = Connection.new(@config["connection"])
|
17
|
+
end
|
18
|
+
|
19
|
+
@mapping = Mapping.new(@config, mappingStore)
|
20
|
+
end
|
21
|
+
|
22
|
+
def mapPath(pathSegments)
|
23
|
+
return @mapping.mapPath(pathSegments)
|
24
|
+
end
|
25
|
+
|
26
|
+
def method_missing(name, *args, &block)
|
27
|
+
if (!@config.has?(name.to_s()))
|
28
|
+
return nil
|
29
|
+
end
|
30
|
+
|
31
|
+
return @config[name.to_s()].value()
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require_relative('../ChangeSetProvider');
|
2
|
+
require_relative('../ChangeSet');
|
3
|
+
require_relative('../FileChange');
|
4
|
+
require_relative('../SysCmd');
|
5
|
+
|
6
|
+
module Disloku
|
7
|
+
module Git
|
8
|
+
|
9
|
+
CHANGE_MAP = {
|
10
|
+
' ' => :nochange,
|
11
|
+
'A' => :added,
|
12
|
+
'C' => :copied,
|
13
|
+
'D' => :deleted,
|
14
|
+
'M' => :modified,
|
15
|
+
'R' => :renamed,
|
16
|
+
'U' => :unmerged,
|
17
|
+
'T' => :typechange,
|
18
|
+
'X' => :unknown,
|
19
|
+
'B' => :broken,
|
20
|
+
}
|
21
|
+
|
22
|
+
class ChangeSetProvider < Disloku::ChangeSetProvider
|
23
|
+
def getChangeSet(from = nil, to = nil)
|
24
|
+
if (from == nil)
|
25
|
+
from = "HEAD"
|
26
|
+
end
|
27
|
+
|
28
|
+
if (to == nil)
|
29
|
+
status = SysCmd.new("git diff --name-status --staged #{from} #{repository.root}").execute()
|
30
|
+
else
|
31
|
+
status = SysCmd.new("git diff --name-status #{from} #{to} #{repository.root}").execute()
|
32
|
+
end
|
33
|
+
|
34
|
+
result = ChangeSet.new()
|
35
|
+
status.output.each_line do |line|
|
36
|
+
match = /^(.)\s+(.*)$/.match(line)
|
37
|
+
result << FileChange.new(repository, match[2], getChangeType(match[1]))
|
38
|
+
end
|
39
|
+
|
40
|
+
return result
|
41
|
+
end
|
42
|
+
|
43
|
+
def getChangeType(changeChar)
|
44
|
+
return CHANGE_MAP[changeChar]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require_relative('../Repository')
|
2
|
+
require_relative('../SysCmd')
|
3
|
+
require_relative('ChangeSetProvider')
|
4
|
+
|
5
|
+
module Disloku
|
6
|
+
module Git
|
7
|
+
class Repository < Disloku::Repository
|
8
|
+
def initialize(location)
|
9
|
+
super(location)
|
10
|
+
end
|
11
|
+
|
12
|
+
def getRepositoryRoot()
|
13
|
+
old = Dir.pwd()
|
14
|
+
Dir.chdir(location)
|
15
|
+
status = SysCmd.new("git rev-parse --show-toplevel").execute()
|
16
|
+
Dir.chdir(old)
|
17
|
+
|
18
|
+
return status.output.strip()
|
19
|
+
end
|
20
|
+
|
21
|
+
def getBranchName()
|
22
|
+
branch = SysCmd.new("git --git-dir=\"#{@gitDir}\" rev-parse --abbrev-ref HEAD").execute()
|
23
|
+
return branch.output.strip()
|
24
|
+
end
|
25
|
+
|
26
|
+
def getProvider()
|
27
|
+
return ChangeSetProvider.new(self)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require_relative('../ChangeSetProvider');
|
2
|
+
require_relative('../ChangeSet');
|
3
|
+
require_relative('../FileChange');
|
4
|
+
require_relative('../SysCmd');
|
5
|
+
|
6
|
+
module Disloku
|
7
|
+
module Svn
|
8
|
+
|
9
|
+
CHANGE_MAP = {
|
10
|
+
' ' => :nochange,
|
11
|
+
'A' => :added,
|
12
|
+
'C' => :conflicted,
|
13
|
+
'D' => :deleted,
|
14
|
+
'I' => :ignored,
|
15
|
+
'M' => :modified,
|
16
|
+
'R' => :replaced,
|
17
|
+
'X' => :external,
|
18
|
+
'?' => :unversioned,
|
19
|
+
'!' => :missing,
|
20
|
+
'~' => :obstructed,
|
21
|
+
}
|
22
|
+
|
23
|
+
class ChangeSetProvider < Disloku::ChangeSetProvider
|
24
|
+
def getChangeSet(from, to)
|
25
|
+
status = SysCmd.new("svn status #{repository.root}").execute()
|
26
|
+
|
27
|
+
result = ChangeSet.new()
|
28
|
+
status.output.each_line do |line|
|
29
|
+
match = /^(.)......\s+(.*)$/.match(line)
|
30
|
+
result << FileChange.new(repository, match[2], getChangeType(match[1]))
|
31
|
+
end
|
32
|
+
|
33
|
+
return result
|
34
|
+
end
|
35
|
+
|
36
|
+
def getChangeType(changeChar)
|
37
|
+
return CHANGE_MAP[changeChar]
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require_relative('../Repository')
|
2
|
+
require_relative('ChangeSetProvider')
|
3
|
+
|
4
|
+
module Disloku
|
5
|
+
module Svn
|
6
|
+
class Repository < Disloku::Repository
|
7
|
+
def initialize(location)
|
8
|
+
super(location)
|
9
|
+
end
|
10
|
+
|
11
|
+
def getRepositoryRoot()
|
12
|
+
info = %x[svn info --xml #{location}]
|
13
|
+
m = /<wcroot-abspath>(.*)<\/wcroot-abspath>/.match(info)
|
14
|
+
return m[1]
|
15
|
+
end
|
16
|
+
|
17
|
+
def getProvider()
|
18
|
+
return ChangeSetProvider.new(self)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require_relative('../Log')
|
2
|
+
require_relative('../BaseTask')
|
3
|
+
require('fileutils')
|
4
|
+
require('stringio')
|
5
|
+
|
6
|
+
module Disloku
|
7
|
+
module Tasks
|
8
|
+
class FolderTask < BaseTask
|
9
|
+
|
10
|
+
def initialize(input)
|
11
|
+
super()
|
12
|
+
@options = getInputParam(input, :options, Options)
|
13
|
+
@changesets = getInputParam(input, :changesets, Array)
|
14
|
+
@target = getInputParam(input, :target, Target)
|
15
|
+
@allowOverride = getInputParam(input, :allowOverride, Object)
|
16
|
+
@deletes = StringIO.new()
|
17
|
+
|
18
|
+
@targetDirectory = File.join(@options.packageDir, @target.name)
|
19
|
+
end
|
20
|
+
|
21
|
+
def beforeExecute()
|
22
|
+
if (!Dir.exists?(@targetDirectory))
|
23
|
+
FileUtils.mkpath(@targetDirectory)
|
24
|
+
elsif (Dir.exists?(@targetDirectory) and !@allowOverride)
|
25
|
+
raise Exception.new("Directory '#{@targetDirectory}' already exists")
|
26
|
+
elsif (Dir.exists?(@targetDirectory))
|
27
|
+
FileUtils.rm_r(@targetDirectory, :force => true)
|
28
|
+
Dir::mkdir(@targetDirectory)
|
29
|
+
end
|
30
|
+
|
31
|
+
@result[:directory] = @targetDirectory
|
32
|
+
@result[:files] = []
|
33
|
+
end
|
34
|
+
|
35
|
+
def executeTask()
|
36
|
+
@changesets.each() do |changeset|
|
37
|
+
changeset.each(&method(:executeOnFileChange))
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def executeOnFileChange(change)
|
42
|
+
file = change.getFile(@target)
|
43
|
+
if (!file.hasMapping?())
|
44
|
+
return
|
45
|
+
end
|
46
|
+
|
47
|
+
destination = file.getAbsoluteDstPath(@targetDirectory)
|
48
|
+
|
49
|
+
case change.changeType
|
50
|
+
when :modified, :added
|
51
|
+
Log.instance.info("adding file #{file.srcPath}")
|
52
|
+
if (!Dir.exists?(File.dirname(destination)))
|
53
|
+
FileUtils.mkpath(File.dirname(destination))
|
54
|
+
end
|
55
|
+
FileUtils.cp(file.srcPath, destination)
|
56
|
+
when :deleted
|
57
|
+
Log.instance.info("adding file #{file.srcPath} to deletion list")
|
58
|
+
addDelete(file.getAbsoluteDstPath())
|
59
|
+
else
|
60
|
+
Log.instance.warn("ignoring change type #{change.changeType}")
|
61
|
+
return
|
62
|
+
end
|
63
|
+
|
64
|
+
@result[:files].push(file)
|
65
|
+
end
|
66
|
+
|
67
|
+
def afterExecute()
|
68
|
+
if (@options.createDeletesFile)
|
69
|
+
File.write(File.join(@targetDirectory, ".deletes"), @deletes.string)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def addDelete(fullPath)
|
74
|
+
@deletes << "#{fullPath}\n"
|
75
|
+
end
|
76
|
+
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
require_relative('../DislokuError')
|
2
|
+
require_relative('../Log')
|
3
|
+
require_relative('../BaseTask')
|
4
|
+
require_relative('../SessionManager')
|
5
|
+
|
6
|
+
module Net; module SFTP; module Operations
|
7
|
+
class Upload
|
8
|
+
alias old_on_mkdir on_mkdir
|
9
|
+
def on_mkdir(response)
|
10
|
+
begin
|
11
|
+
old_on_mkdir(response)
|
12
|
+
rescue
|
13
|
+
if (@options[:ignoreMkdirError])
|
14
|
+
process_next_entry
|
15
|
+
else
|
16
|
+
raise
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end; end; end
|
22
|
+
|
23
|
+
module Disloku
|
24
|
+
module Tasks
|
25
|
+
class NetSftpTask < BaseTask
|
26
|
+
|
27
|
+
def initialize(input)
|
28
|
+
super()
|
29
|
+
@repository = getInputParam(input, :repository, Repository)
|
30
|
+
@options = getInputParam(input, :options, Options)
|
31
|
+
@directory = getInputParam(input, :directory, String)
|
32
|
+
@files = getInputParam(input, :files, Array)
|
33
|
+
@target = getInputParam(input, :target, Target)
|
34
|
+
end
|
35
|
+
|
36
|
+
def beforeExecute()
|
37
|
+
if (!@target.branchLock.nil?)
|
38
|
+
branch = @repository.getBranchName()
|
39
|
+
if (branch != @target.branchLock)
|
40
|
+
raise DislokuError.new("Target [#{@target.name}] is locked to branch #{@target.branchLock} but current branch is #{branch}")
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
puts()
|
45
|
+
puts("Target [#{@target.name}]: #{@target.user}@#{@target.host}:#{@target.targetDir}")
|
46
|
+
@files.each() do |file|
|
47
|
+
puts(file)
|
48
|
+
end
|
49
|
+
puts()
|
50
|
+
puts("Continue with deployment (Y/N)?")
|
51
|
+
response = STDIN.readline().chomp()
|
52
|
+
@skip = response.match(/^[Yy]/) == nil
|
53
|
+
puts("skipping: #{@skip}")
|
54
|
+
end
|
55
|
+
|
56
|
+
def executeTask()
|
57
|
+
if (@skip)
|
58
|
+
return
|
59
|
+
end
|
60
|
+
|
61
|
+
SessionManager.instance.get(@target.connection) do |sftp|
|
62
|
+
Log.instance.info("copying new files...")
|
63
|
+
sftp.upload!(@directory, @target.targetDir, { :ignoreMkdirError => true }) do |event, uploader, *args|
|
64
|
+
case event
|
65
|
+
when :open then
|
66
|
+
# args[0] : file metadata
|
67
|
+
# "starting upload: #{args[0].local} -> #{args[0].remote} (#{args[0].size} bytes}"
|
68
|
+
print(".")
|
69
|
+
end
|
70
|
+
end
|
71
|
+
puts()
|
72
|
+
|
73
|
+
@files.each() do |file|
|
74
|
+
if (file.change.changeType == :deleted)
|
75
|
+
path = file.getAbsoluteDstPath()
|
76
|
+
Log.instance.info("deleting file #{path}")
|
77
|
+
begin
|
78
|
+
sftp.remove!(path)
|
79
|
+
rescue
|
80
|
+
if (!@options.ignoreDeleteErrors)
|
81
|
+
raise
|
82
|
+
else
|
83
|
+
Log.instance.info("unable to delete file #{path} (it probably doesn't exist)")
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def afterExecute()
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require_relative('../Log')
|
2
|
+
require_relative('../BaseTask')
|
3
|
+
require('Open3')
|
4
|
+
|
5
|
+
module Disloku
|
6
|
+
module Tasks
|
7
|
+
# class PsFtpTask < BaseTask
|
8
|
+
# def initialize(stream, config, changesets)
|
9
|
+
# super(stream, changesets)
|
10
|
+
# @config = config.yaml["psftp"]
|
11
|
+
# end
|
12
|
+
|
13
|
+
# def beforeExecute()
|
14
|
+
# Log.instance.info(@config["path"])
|
15
|
+
# env = @config["env"][0]
|
16
|
+
# Log.instance.info(env)
|
17
|
+
# cmd = "\"#{@config['path']}\""
|
18
|
+
|
19
|
+
# if (env.has_key?("key"))
|
20
|
+
# cmd += " -i \"#{env['key']}\""
|
21
|
+
# else
|
22
|
+
# cmd += " -pw #{env['password']}"
|
23
|
+
# end
|
24
|
+
|
25
|
+
# cmd += " #{env['user']}@#{env['host']}"
|
26
|
+
|
27
|
+
# Log.instance.info(cmd)
|
28
|
+
|
29
|
+
# @stream, @output = Open3.popen2(cmd)
|
30
|
+
# writeLine("cd #{env['targetDir']}")
|
31
|
+
# end
|
32
|
+
|
33
|
+
# def executeTask(changeset)
|
34
|
+
# changeset.each() do |change|
|
35
|
+
# destination = change.file.segments.join("/")
|
36
|
+
# case change.changeType
|
37
|
+
# when :modified, :added
|
38
|
+
# path = change.file.getPathSegments().join('/')
|
39
|
+
# Log.instance.info("mkdir \"#{path}\"")
|
40
|
+
# writeLine("mkdir \"#{path}\"")
|
41
|
+
# Log.instance.info("put \"#{change.file.filePath}\" \"#{destination}\"")
|
42
|
+
# writeLine("put \"#{change.file.filePath}\" \"#{destination}\"")
|
43
|
+
# when :deleted
|
44
|
+
# writeLine("del \"#{destination}\"")
|
45
|
+
# else
|
46
|
+
# Log.instance.info("ignoring change type #{change.changeType}")
|
47
|
+
# end
|
48
|
+
# end
|
49
|
+
# end
|
50
|
+
|
51
|
+
# def afterExecute()
|
52
|
+
# writeLine("quit")
|
53
|
+
|
54
|
+
# @output.readlines.each(&Log.instance.method(:info))
|
55
|
+
# end
|
56
|
+
# end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Disloku
|
2
|
+
module Util
|
3
|
+
|
4
|
+
SPLIT_EXP = "\\#{File::SEPARATOR}|\\#{File::ALT_SEPARATOR}"
|
5
|
+
|
6
|
+
class File
|
7
|
+
attr_accessor :srcPath, :change
|
8
|
+
|
9
|
+
def initialize(filePath, basePath, target, change)
|
10
|
+
@srcPath = filePath
|
11
|
+
@target = target
|
12
|
+
@change = change
|
13
|
+
|
14
|
+
fileSegments = filePath.split(/#{SPLIT_EXP}/)
|
15
|
+
baseSegments = basePath.split(/#{SPLIT_EXP}/)
|
16
|
+
index = 0
|
17
|
+
while (fileSegments[index] == baseSegments[index])
|
18
|
+
index += 1
|
19
|
+
end
|
20
|
+
|
21
|
+
@relativeSrcSegments = fileSegments[index..-1]
|
22
|
+
@relativeDstSegments = target.mapPath(@relativeSrcSegments)
|
23
|
+
end
|
24
|
+
|
25
|
+
def getRelativeDstSegments()
|
26
|
+
return @segments
|
27
|
+
end
|
28
|
+
|
29
|
+
def getRelativeDirSegments()
|
30
|
+
return @segments[0..-2]
|
31
|
+
end
|
32
|
+
|
33
|
+
def hasMapping?()
|
34
|
+
return @relativeDstSegments != nil
|
35
|
+
end
|
36
|
+
|
37
|
+
def getAbsoluteDstPath(basePath = nil)
|
38
|
+
basePath = basePath || @target.targetDir
|
39
|
+
return ::File.join(basePath, *@relativeDstSegments)
|
40
|
+
end
|
41
|
+
|
42
|
+
def to_s()
|
43
|
+
return "#{srcPath} -> #{getAbsoluteDstPath()}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
data/lib/disloku.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require_relative('disloku/Disloku')
|
2
|
+
require_relative('disloku/SysCmd')
|
3
|
+
require_relative('disloku/SessionManager')
|
4
|
+
require('thor')
|
5
|
+
|
6
|
+
class DislokuCli < Thor
|
7
|
+
desc "deploy [FROM] [TO]", "deploy changes"
|
8
|
+
method_option :dir, :default => ".", :aliases => "-d", :desc => "repository directory"
|
9
|
+
method_option :target, :aliases => "-t", :desc => "target"
|
10
|
+
def deploy(from = nil, to = nil)
|
11
|
+
puts "deploy #{options.inspect}"
|
12
|
+
|
13
|
+
disloku = Disloku::Disloku.new(options)
|
14
|
+
disloku.deployPackage(from, to)
|
15
|
+
end
|
16
|
+
|
17
|
+
desc "build [FROM] [TO]", "build change package"
|
18
|
+
method_option :dir, :default => ".", :aliases => "-d", :desc => "repository directory"
|
19
|
+
method_option :target, :aliases => "-t", :desc => "target"
|
20
|
+
def build(from = nil, to = nil)
|
21
|
+
p(options)
|
22
|
+
disloku = Disloku::Disloku.new(options)
|
23
|
+
dir = disloku.buildPackage(from, to)
|
24
|
+
end
|
25
|
+
|
26
|
+
desc "config", "show configuration"
|
27
|
+
method_option :dir, :default => ".", :aliases => "-d", :desc => "repository directory"
|
28
|
+
def config()
|
29
|
+
disloku = Disloku::Disloku.new(options)
|
30
|
+
puts(disloku.config.to_yaml())
|
31
|
+
end
|
32
|
+
end
|
metadata
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: disloku
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Lukas Angerer
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-03-05 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: A deployment tool that allows copying of Git changesets via sftp
|
14
|
+
email: executor.dev@gmail.com
|
15
|
+
executables:
|
16
|
+
- disloku
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- lib/disloku/BaseTask.rb
|
21
|
+
- lib/disloku/ChangeSet.rb
|
22
|
+
- lib/disloku/ChangeSetProvider.rb
|
23
|
+
- lib/disloku/Commit.rb
|
24
|
+
- lib/disloku/Config.rb
|
25
|
+
- lib/disloku/Connection.rb
|
26
|
+
- lib/disloku/ConnectionStore.rb
|
27
|
+
- lib/disloku/Disloku.rb
|
28
|
+
- lib/disloku/DislokuError.rb
|
29
|
+
- lib/disloku/FileChange.rb
|
30
|
+
- lib/disloku/git/ChangeSetProvider.rb
|
31
|
+
- lib/disloku/git/Repository.rb
|
32
|
+
- lib/disloku/Log.rb
|
33
|
+
- lib/disloku/Mapping.rb
|
34
|
+
- lib/disloku/MappingStore.rb
|
35
|
+
- lib/disloku/NamedConfigStore.rb
|
36
|
+
- lib/disloku/Options.rb
|
37
|
+
- lib/disloku/Repository.rb
|
38
|
+
- lib/disloku/SessionManager.rb
|
39
|
+
- lib/disloku/svn/ChangeSetProvider.rb
|
40
|
+
- lib/disloku/svn/Repository.rb
|
41
|
+
- lib/disloku/SysCmd.rb
|
42
|
+
- lib/disloku/SysCmdResult.rb
|
43
|
+
- lib/disloku/Target.rb
|
44
|
+
- lib/disloku/tasks/FolderTask.rb
|
45
|
+
- lib/disloku/tasks/NetSftpTask.rb
|
46
|
+
- lib/disloku/tasks/PsFtpTask.rb
|
47
|
+
- lib/disloku/util/File.rb
|
48
|
+
- lib/disloku/util/Hash.rb
|
49
|
+
- lib/disloku.rb
|
50
|
+
- bin/disloku
|
51
|
+
homepage:
|
52
|
+
licenses:
|
53
|
+
- MIT
|
54
|
+
metadata: {}
|
55
|
+
post_install_message:
|
56
|
+
rdoc_options: []
|
57
|
+
require_paths:
|
58
|
+
- lib
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - '>='
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0'
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - '>='
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
requirements: []
|
70
|
+
rubyforge_project:
|
71
|
+
rubygems_version: 2.0.14
|
72
|
+
signing_key:
|
73
|
+
specification_version: 4
|
74
|
+
summary: Disloku deployment tool
|
75
|
+
test_files: []
|