shuttl 0.3.1
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/shuttl +140 -0
- data/src/Shuttl/Loader.rb +39 -0
- data/src/Shuttl/Shuttl.rb +9 -0
- data/src/commands/base.rb +48 -0
- data/src/commands/build.rb +39 -0
- data/src/commands/deploy.rb +46 -0
- data/src/commands/info.rb +50 -0
- data/src/commands/install.rb +38 -0
- data/src/commands/run.rb +21 -0
- data/src/commands/ssh.rb +29 -0
- data/src/commands/start.rb +77 -0
- data/src/commands/stop.rb +18 -0
- data/src/dsl/Shuttl.rb +100 -0
- data/src/dsl/buildContext.rb +209 -0
- data/src/dsl/eval.rb +14 -0
- data/src/main.rb +0 -0
- data/src/settings.rb +4 -0
- metadata +115 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA1:
|
|
3
|
+
metadata.gz: cdced374d119718cdd22b9d9886632a5eda06260
|
|
4
|
+
data.tar.gz: 621609539b377b2f2f78f8839089a136de80bd6c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8073a8497ab099c178a28bf200264e79c12f01483727da95fe89442048a98ce4736b1ef5b2a36ef8574b68dd587b057247982f72d0a37449c974489556636a43
|
|
7
|
+
data.tar.gz: 95d6d4e9a61d177012f7ce0e68f612b0db3ab4fe152ee5caa5ed278d20be32d774003d7e9e9b95fe2e474f8297534648fd50bafca870b03fb674a1c74966e1a9
|
data/bin/shuttl
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/ruby
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require 'colorize'
|
|
5
|
+
require_relative '../src/dsl/eval'
|
|
6
|
+
require_relative '../src/commands/start'
|
|
7
|
+
require_relative '../src/commands/stop'
|
|
8
|
+
require_relative '../src/commands/build'
|
|
9
|
+
require_relative '../src/commands/info'
|
|
10
|
+
require_relative '../src/commands/run'
|
|
11
|
+
require_relative '../src/commands/ssh'
|
|
12
|
+
require_relative '../src/commands/deploy'
|
|
13
|
+
require_relative '../src/commands/install'
|
|
14
|
+
require_relative '../src/settings'
|
|
15
|
+
|
|
16
|
+
Docker.validate_version!
|
|
17
|
+
|
|
18
|
+
VERSION = "#{ShuttlSettings::VERSION} build #{ShuttlSettings::BUILD}"
|
|
19
|
+
|
|
20
|
+
options = {
|
|
21
|
+
:fileName => './Shuttlfile'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
globals = OptionParser.new do |opts|
|
|
25
|
+
opts.banner = "Usage: shuttl [command]"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
subcommands = Hash[
|
|
29
|
+
"start" => {
|
|
30
|
+
:opts => OptionParser.new do |opts|
|
|
31
|
+
opts.banner = "Starts a new instance of the app"
|
|
32
|
+
opts.on('--file FILE', 'Defines the file to use as the build file') do |file|
|
|
33
|
+
options[:fileName] = file
|
|
34
|
+
end
|
|
35
|
+
options[:stage] = 'dev'
|
|
36
|
+
end,
|
|
37
|
+
:runner => Start.new
|
|
38
|
+
},
|
|
39
|
+
'build' => {
|
|
40
|
+
:opts => OptionParser.new do |opts|
|
|
41
|
+
options[:stage] = 'dev'
|
|
42
|
+
options[:clean] = false
|
|
43
|
+
options[:env] = nil
|
|
44
|
+
opts.on('--file FILE', 'Defines the file to use as the build file') do |file|
|
|
45
|
+
options[:fileName] = file
|
|
46
|
+
end
|
|
47
|
+
opts.on('-s STAGE', '--stage STAGE', 'The build stage') do |stage|
|
|
48
|
+
options[:stage] = stage
|
|
49
|
+
end
|
|
50
|
+
opts.on('--clean', 'Preform a clean build (Don\'t use the cache)') do
|
|
51
|
+
options[:clean] = true
|
|
52
|
+
end
|
|
53
|
+
opts.on('--env FILE', 'defines the build arguments to use') do |file|
|
|
54
|
+
options[:env] = file
|
|
55
|
+
end
|
|
56
|
+
end,
|
|
57
|
+
:runner => Build.new
|
|
58
|
+
},
|
|
59
|
+
'show' => {
|
|
60
|
+
:opts => OptionParser.new do |opts|
|
|
61
|
+
options[:stage] = 'dev'
|
|
62
|
+
options[:showIP] = false
|
|
63
|
+
options[:showDocker] = false
|
|
64
|
+
options[:container] = false
|
|
65
|
+
options[:status] = false
|
|
66
|
+
opts.on('--docker-file', 'Show the docker file') do |file|
|
|
67
|
+
options[:showDocker] = true
|
|
68
|
+
end
|
|
69
|
+
opts.on('--file FILE', 'Defines the file to use as the build file') do |file|
|
|
70
|
+
options[:fileName] = file
|
|
71
|
+
end
|
|
72
|
+
opts.on('-s STAGE', '--stage STAGE', 'The build stage') do |stage|
|
|
73
|
+
options[:stage] = stage
|
|
74
|
+
end
|
|
75
|
+
opts.on('--ip', 'Shows the IP address of the running container') do
|
|
76
|
+
options[:showIP] = true
|
|
77
|
+
end
|
|
78
|
+
opts.on('--container', 'Shows the container\'s json information') do
|
|
79
|
+
options[:container] = true
|
|
80
|
+
end
|
|
81
|
+
opts.on('--status', 'Shows the container\'s status') do
|
|
82
|
+
options[:status] = true
|
|
83
|
+
end
|
|
84
|
+
end,
|
|
85
|
+
:runner => Info.new
|
|
86
|
+
},
|
|
87
|
+
'stop' => {
|
|
88
|
+
:opts => OptionParser.new do |opts|
|
|
89
|
+
options[:stage] = 'dev'
|
|
90
|
+
end,
|
|
91
|
+
:runner => Stop.new
|
|
92
|
+
},
|
|
93
|
+
'run' => {
|
|
94
|
+
:opts => OptionParser.new do |opts|
|
|
95
|
+
end,
|
|
96
|
+
:runner => Run.new
|
|
97
|
+
},
|
|
98
|
+
'ssh' => {
|
|
99
|
+
:opts => OptionParser.new do |opts|
|
|
100
|
+
end,
|
|
101
|
+
:runner => SSH.new
|
|
102
|
+
},
|
|
103
|
+
'deploy' => {
|
|
104
|
+
:opts => OptionParser.new do |opts|
|
|
105
|
+
options[:stage] = 'production'
|
|
106
|
+
opts.on('-s STAGE', '--stage STAGE', 'The build stage') do |stage|
|
|
107
|
+
options[:stage] = stage
|
|
108
|
+
end
|
|
109
|
+
opts.on('-t TAG', '--registry TAG', 'The registry to push to') do |tag|
|
|
110
|
+
options[:tag] = tag
|
|
111
|
+
end
|
|
112
|
+
end,
|
|
113
|
+
:runner => Deploy.new
|
|
114
|
+
},
|
|
115
|
+
'install' => {
|
|
116
|
+
:opts => OptionParser.new do |opts|
|
|
117
|
+
options[:installPath] = "#{Dir.home}/.shuttl"
|
|
118
|
+
opts.on('-p PATH', '--install-path PATH', 'The path to install to') do |path|
|
|
119
|
+
options[:installPath] = path
|
|
120
|
+
end
|
|
121
|
+
end,
|
|
122
|
+
:runner => Install.new
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
global = OptionParser.new do |opts|
|
|
127
|
+
opts.banner = "Usage: shuttl [options] [subcommand [options]]"
|
|
128
|
+
opts.on('--version', 'get the version of the app') do
|
|
129
|
+
options[:skip_all] = true
|
|
130
|
+
puts "Shuttl Version: #{VERSION}".green
|
|
131
|
+
end
|
|
132
|
+
# ...
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
global.order!
|
|
136
|
+
if !options[:skip_all]
|
|
137
|
+
command = ARGV.shift
|
|
138
|
+
subcommands[command][:opts].order!
|
|
139
|
+
subcommands[command][:runner].handle options, ARGV
|
|
140
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
|
|
2
|
+
class Loader
|
|
3
|
+
# require '../dsl/eval'
|
|
4
|
+
def initialize ()
|
|
5
|
+
@dirs = []
|
|
6
|
+
shuttlDir = File.join(Dir.home, '.shuttl/definitions')
|
|
7
|
+
@dirs << shuttlDir
|
|
8
|
+
if ENV.key('SHUTTL_PATH')
|
|
9
|
+
@dirs << ENV['SHUTTL_PATH'].split(":")
|
|
10
|
+
end
|
|
11
|
+
@dirs << Dir.getwd
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def find (name, stage)
|
|
15
|
+
found = nil
|
|
16
|
+
[name, "#{name}.shuttlfile"].each do |fileName|
|
|
17
|
+
found = findFile(fileName, stage)
|
|
18
|
+
if !found.nil?
|
|
19
|
+
break
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
if found.nil?
|
|
23
|
+
throw "No shuttl file found"
|
|
24
|
+
end
|
|
25
|
+
found
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def findFile (file, stage)
|
|
29
|
+
found = nil
|
|
30
|
+
@dirs.each do | dir |
|
|
31
|
+
potentialFileName = File.join(dir, file)
|
|
32
|
+
if File.exist? potentialFileName
|
|
33
|
+
found = ShuttlDSL.load potentialFileName, stage
|
|
34
|
+
break
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
found
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
class CommandBase
|
|
4
|
+
def initialize
|
|
5
|
+
@fileLocation = File.join(Dir.home, '.shuttl')
|
|
6
|
+
File.open(File.join(@fileLocation, 'info'), 'r') do |fi|
|
|
7
|
+
@info = JSON.parse(fi.read)
|
|
8
|
+
end
|
|
9
|
+
@cwd = Dir.getwd
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def handle (options, args)
|
|
13
|
+
@args = args
|
|
14
|
+
self.run options
|
|
15
|
+
self.cleanUp options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run (options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def cleanUp (options)
|
|
22
|
+
File.open(File.join(Dir.home, '.shuttl/info'), 'w') do |fi|
|
|
23
|
+
fi.write @info.to_json
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def hasImage?
|
|
28
|
+
hasImage = @info['images'].key?(@cwd)
|
|
29
|
+
if hasImage
|
|
30
|
+
@current_image_info = @info['images'][@cwd]
|
|
31
|
+
@image = Docker::Image.get(@info['images'][@cwd]["image_id"])
|
|
32
|
+
end
|
|
33
|
+
hasImage
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def isRunning?
|
|
37
|
+
if (@info['containers'].key?(@cwd))
|
|
38
|
+
begin
|
|
39
|
+
@container = Docker::Container.get(@info['containers'][@cwd]["container_id"])
|
|
40
|
+
rescue Docker::Error::NotFoundError
|
|
41
|
+
@info['containers'].delete(@cwd)
|
|
42
|
+
return false
|
|
43
|
+
end
|
|
44
|
+
return @container.json['State']['Running']
|
|
45
|
+
end
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require 'docker'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
require_relative '../dsl/eval'
|
|
5
|
+
require_relative 'base'
|
|
6
|
+
|
|
7
|
+
class Build < CommandBase
|
|
8
|
+
|
|
9
|
+
def build (options)
|
|
10
|
+
Docker.options[:read_timeout] = 180
|
|
11
|
+
$stdout.print "Building new image\n"
|
|
12
|
+
file = File.expand_path(options[:fileName], Dir.getwd)
|
|
13
|
+
shuttlConfig = ShuttlDSL.load file, options[:stage]
|
|
14
|
+
shuttlConfig.setEnvFile options[:env]
|
|
15
|
+
# tar = shuttlConfig.makeImage options[:stage], @cwd
|
|
16
|
+
begin
|
|
17
|
+
step = 1
|
|
18
|
+
@image = shuttlConfig.build options[:stage], @cwd, options[:clean] do |v|
|
|
19
|
+
if (log = JSON.parse(v)) && log.has_key?("stream")
|
|
20
|
+
$stdout.puts log['stream']
|
|
21
|
+
if log['stream'].include? 'Step'
|
|
22
|
+
step += 1
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
if !shuttlConfig.getName().nil?
|
|
27
|
+
@image.tag('repo' => shuttlConfig.getName())
|
|
28
|
+
end
|
|
29
|
+
@info['images'][@cwd] = {:image_id => @image.id, :volumes => shuttlConfig.gatherVolume(options[:stage]), :built => Time.now.to_i, :stage => options[:stage]}
|
|
30
|
+
rescue Docker::Error::UnexpectedResponseError => error
|
|
31
|
+
$stderr.puts "Build Failed!".red
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run (options)
|
|
36
|
+
self.build options
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require 'docker'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'colorize'
|
|
4
|
+
|
|
5
|
+
require_relative '../dsl/eval'
|
|
6
|
+
require_relative 'base'
|
|
7
|
+
|
|
8
|
+
class Deploy < CommandBase
|
|
9
|
+
|
|
10
|
+
def build (options)
|
|
11
|
+
$stdout.print "Building new image\n"
|
|
12
|
+
file = File.expand_path(options[:fileName], Dir.getwd)
|
|
13
|
+
shuttlConfig = ShuttlDSL.load file, options[:stage]
|
|
14
|
+
# tar = shuttlConfig.makeImage options[:stage], @cwd
|
|
15
|
+
begin
|
|
16
|
+
step = 1
|
|
17
|
+
@image = shuttlConfig.build options[:stage], @cwd, true do |v|
|
|
18
|
+
if (log = JSON.parse(v)) && log.has_key?("stream")
|
|
19
|
+
$stdout.puts log['stream']
|
|
20
|
+
if log['stream'].include? 'Step'
|
|
21
|
+
step += 1
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
if !shuttlConfig.getName().nil?
|
|
26
|
+
@image.tag('repo' => shuttlConfig.getName())
|
|
27
|
+
end
|
|
28
|
+
@info['images'][@cwd] = {:image_id => @image.id, :volumes => shuttlConfig.gatherVolume(options[:stage]), :built => Time.now.to_i, :stage => options[:stage]}
|
|
29
|
+
rescue Docker::Error::UnexpectedResponseError => error
|
|
30
|
+
$stderr.puts "Build Failed!".red
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run (options)
|
|
35
|
+
Docker.authenticate!
|
|
36
|
+
self.build options
|
|
37
|
+
`$(aws ecr get-login --no-include-email --region us-east-2)`
|
|
38
|
+
@image.tag('repo' => 'shuttl/django', 'tag' => options[:stage])
|
|
39
|
+
@image.tag('repo' => options[:tag], 'tag' => 'latest', force: true)
|
|
40
|
+
@image.push(nil, repo_tag: "#{options[:tag]}") do |stream, chunk|
|
|
41
|
+
$stdout.puts "#{stream}: #{chunk}"
|
|
42
|
+
end
|
|
43
|
+
$stdout.puts "Deploy done!".green
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require 'docker'
|
|
2
|
+
require 'colorize'
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
require_relative '../dsl/eval'
|
|
6
|
+
require_relative 'base'
|
|
7
|
+
|
|
8
|
+
class Info < CommandBase
|
|
9
|
+
|
|
10
|
+
def build (options)
|
|
11
|
+
file = File.expand_path(options[:fileName], Dir.getwd)
|
|
12
|
+
shuttlConfig = ShuttlDSL.load file, options[:stage]
|
|
13
|
+
# tar = shuttlConfig.makeImage options[:stage], @cwd
|
|
14
|
+
$stdout.puts shuttlConfig.makeDockerFile options[:stage]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run (options)
|
|
18
|
+
if options[:showDocker]
|
|
19
|
+
self.build options
|
|
20
|
+
end
|
|
21
|
+
if options[:showIP]
|
|
22
|
+
if isRunning?
|
|
23
|
+
$stdout.puts "IP Address: #{@info['containers'][@cwd]["json"]['NetworkSettings']['IPAddress']}".green
|
|
24
|
+
else
|
|
25
|
+
$stderr.puts "No container running for this dir!".red
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
if options[:container]
|
|
29
|
+
if isRunning?
|
|
30
|
+
puts JSON.pretty_generate(@container.json)
|
|
31
|
+
else
|
|
32
|
+
$stderr.puts "No container running for this dir!".red
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
if options[:status]
|
|
36
|
+
if isRunning?
|
|
37
|
+
if @container.json["State"]['Running']
|
|
38
|
+
$stdout.puts "Shuttl is up and running!".green
|
|
39
|
+
else
|
|
40
|
+
@container.json["State"].each do |name, value|
|
|
41
|
+
puts "#{name}: #{value}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
$stderr.puts "No container running for this dir!".red
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require 'docker'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'colorize'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'zip'
|
|
6
|
+
|
|
7
|
+
require_relative '../dsl/eval'
|
|
8
|
+
require_relative 'base'
|
|
9
|
+
|
|
10
|
+
class Install < CommandBase
|
|
11
|
+
|
|
12
|
+
def run (options)
|
|
13
|
+
tempFile = Tempfile.new('shuttl.zip')
|
|
14
|
+
Net::HTTP.start("s3.us-east-2.amazonaws.com") do |http|
|
|
15
|
+
resp = http.get("/shuttl-cli/shuttlinfo.zip")
|
|
16
|
+
tempFile.write(resp.body)
|
|
17
|
+
end
|
|
18
|
+
tempFile.close
|
|
19
|
+
Zip::File.open(tempFile.path) do |zip_file|
|
|
20
|
+
# Handle entries one by one
|
|
21
|
+
zip_file.each do |entry|
|
|
22
|
+
# Extract to file/directory/symlink
|
|
23
|
+
entry.extract(File.join(options[:installPath], entry.name.gsub('.shuttl/', ''))) {true}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
tempFile.unlink
|
|
27
|
+
|
|
28
|
+
base = {
|
|
29
|
+
:images => {},
|
|
30
|
+
:containers => {},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
File.open(File.join(Dir.home, '.shuttl/info'), 'w') do |fi|
|
|
34
|
+
fi.write base.to_json
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
end
|
data/src/commands/run.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require 'docker'
|
|
2
|
+
require 'colorize'
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
class Run < CommandBase
|
|
7
|
+
|
|
8
|
+
def run (options)
|
|
9
|
+
if !isRunning?
|
|
10
|
+
$stdout.puts "Shuttl not running! run shuttl start".red
|
|
11
|
+
return
|
|
12
|
+
end
|
|
13
|
+
fd = IO.sysopen "/dev/tty", "w"
|
|
14
|
+
ios = IO.new(fd, "w")
|
|
15
|
+
output = @container.exec(@args, tty: true) do |stream|
|
|
16
|
+
ios.print stream
|
|
17
|
+
end
|
|
18
|
+
ios.close
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
end
|
data/src/commands/ssh.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require 'docker'
|
|
2
|
+
require 'colorize'
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
class SSH < CommandBase
|
|
7
|
+
|
|
8
|
+
def run (options)
|
|
9
|
+
if !isRunning?
|
|
10
|
+
$stderr.puts "Shuttl not running! run shuttl start".red
|
|
11
|
+
return
|
|
12
|
+
end
|
|
13
|
+
fd = IO.sysopen "/dev/tty", "w"
|
|
14
|
+
ios = IO.new(fd, "w")
|
|
15
|
+
ios.raw!
|
|
16
|
+
begin
|
|
17
|
+
@container.exec(['/bin/bash'], stdin: $stdin, tty: true, stdout: true, stderr: true, stream: true) do |stream|
|
|
18
|
+
ios.print stream
|
|
19
|
+
end
|
|
20
|
+
rescue Interrupt => e
|
|
21
|
+
rescue Exception => e
|
|
22
|
+
$stderr.puts "Error: #{e}".red
|
|
23
|
+
end
|
|
24
|
+
ios.close
|
|
25
|
+
## The space is put before the command so that it won't show up in bash history
|
|
26
|
+
` stty sane`
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require 'docker'
|
|
2
|
+
require 'colorize'
|
|
3
|
+
|
|
4
|
+
require_relative '../dsl/eval'
|
|
5
|
+
require_relative 'base'
|
|
6
|
+
|
|
7
|
+
class Booted < StandardError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Start < CommandBase
|
|
11
|
+
|
|
12
|
+
def build (options)
|
|
13
|
+
$stdout.print "Building new image\n"
|
|
14
|
+
file = File.expand_path(options[:fileName], Dir.getwd)
|
|
15
|
+
shuttlConfig = ShuttlDSL.load file, options[:stage]
|
|
16
|
+
begin
|
|
17
|
+
step = 1
|
|
18
|
+
@image = shuttlConfig.build options[:stage], @cwd do |v|
|
|
19
|
+
if (log = JSON.parse(v)) && log.has_key?("stream")
|
|
20
|
+
$stdout.puts log['stream']
|
|
21
|
+
if log['stream'].include? 'Step'
|
|
22
|
+
step += 1
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
if !shuttlConfig.getName().nil?
|
|
27
|
+
@image.tag('repo' => shuttlConfig.getName())
|
|
28
|
+
end
|
|
29
|
+
@info['images'][@cwd] = {:image_id => @image.id, :volumes => shuttlConfig.gatherVolume(options[:stage]), :built => Time.now.to_i}
|
|
30
|
+
rescue Docker::Error::UnexpectedResponseError => error
|
|
31
|
+
$stderr.puts error
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def startContainer (options)
|
|
36
|
+
$stdout.puts "Starting image"
|
|
37
|
+
@container = Docker::Container.create(
|
|
38
|
+
'Image' => @image.id,
|
|
39
|
+
'HostConfig' => {
|
|
40
|
+
"Binds" => @current_image_info['volumes'].map {|mountPoint, hostDir| "#{hostDir}:#{mountPoint}"}
|
|
41
|
+
}
|
|
42
|
+
).start
|
|
43
|
+
begin
|
|
44
|
+
catch (Booted) do
|
|
45
|
+
@container.tap(&:start).attach do |stream, chunk|
|
|
46
|
+
if chunk.include? 'SHUTTL IMAGE BOOTED'
|
|
47
|
+
throw Booted
|
|
48
|
+
end
|
|
49
|
+
$stdout.puts "#{stream}: #{chunk}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
$stdout.puts "Container is fully booted and ready for use!".green
|
|
53
|
+
$stdout.puts "Container's id is #{@container.id} and the IP is: #{@container.json['NetworkSettings']['IPAddress']}".green
|
|
54
|
+
rescue Exception => e
|
|
55
|
+
$std.puts "error: #{e}"
|
|
56
|
+
end
|
|
57
|
+
@info['containers'][@cwd] = {:container_id => @container.id, :status => 'running', :json => @container.json }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def shouldRebuild?(mtime)
|
|
61
|
+
@current_image_info["built"] < mtime.to_i
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run (options)
|
|
66
|
+
if self.isRunning?
|
|
67
|
+
$stdout.puts "Already running!"
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
if self.hasImage? && !shouldRebuild?(File.mtime(File.expand_path(options[:fileName], Dir.getwd)))
|
|
71
|
+
return self.startContainer options
|
|
72
|
+
end
|
|
73
|
+
self.build options
|
|
74
|
+
self.startContainer options
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require 'docker'
|
|
2
|
+
|
|
3
|
+
require_relative '../dsl/eval'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
class Stop < CommandBase
|
|
7
|
+
|
|
8
|
+
def run (options)
|
|
9
|
+
if self.isRunning?
|
|
10
|
+
$stdout.puts "Stopping Shuttl instance"
|
|
11
|
+
@container.kill
|
|
12
|
+
else
|
|
13
|
+
$stdout.puts "Not running?"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
end
|
data/src/dsl/Shuttl.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
## Base class for the DSL definition
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'docker'
|
|
4
|
+
|
|
5
|
+
class Shuttl
|
|
6
|
+
|
|
7
|
+
attr_accessor :name, :stage
|
|
8
|
+
|
|
9
|
+
def initialize (builder)
|
|
10
|
+
@builder = builder
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def DEFINES (name)
|
|
14
|
+
@builder.setName(name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add (command)
|
|
18
|
+
@builder.add command
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fileAdd (source)
|
|
22
|
+
@builder.fileAdd source
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def FROM (name)
|
|
26
|
+
self.add "FROM #{name}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def RUN (name)
|
|
30
|
+
self.add "RUN #{name}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ADD (source, destination)
|
|
34
|
+
if source !=~ /\A#{URI::regexp}\z/
|
|
35
|
+
@builder.fileAdd(source, destination)
|
|
36
|
+
else
|
|
37
|
+
self.add "ADD #{source} #{destination}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def COPY(source, destination)
|
|
42
|
+
self.add "COPY #{source} #{destination}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def EXPOSE (port)
|
|
46
|
+
self.add "EXPOSE #{port}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ENTRYPOINT (entrypoint)
|
|
50
|
+
@builder.entrypoint = entrypoint
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ONSTART (cmd)
|
|
54
|
+
self.add "RUN echo \"#{cmd}\" >> /.shuttl/run"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ONRUN (cmd)
|
|
58
|
+
self.add "RUN echo \"#{cmd}\" >> /.shuttl/start"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def EXTENDS (name)
|
|
62
|
+
require_relative '../Shuttl/Loader'
|
|
63
|
+
loader = Loader.new
|
|
64
|
+
@builder.merge loader.find name, @builder.buildStage
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def USE (name)
|
|
68
|
+
EXTENDS name
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def SET (setting, value)
|
|
72
|
+
@builder.set setting, value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def ON (name)
|
|
76
|
+
@builder.on name do
|
|
77
|
+
yield
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def VOLUME (volume)
|
|
82
|
+
self.add "VOLUME #{volume}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ATTACH (localDir, volume)
|
|
86
|
+
@builder.attach(localDir, volume)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ENVIRONMENT (tester)
|
|
90
|
+
tester.call(@builder.buildSettings[:settings]['ENVIRONMENT'])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def IS (name)
|
|
94
|
+
proc {|value| value == name }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def CMD (cmd)
|
|
98
|
+
@builder.cmd cmd
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
class Builder
|
|
2
|
+
|
|
3
|
+
attr_accessor :stage, :buildStage, :buildSettings, :volumes, :entrypoint
|
|
4
|
+
def initialize (fileLocation, stage=nil, name=nil)
|
|
5
|
+
@name = name
|
|
6
|
+
@buildStage = stage
|
|
7
|
+
@buildSettings = { :settings => {}, :docker => [], :files => {}, :volumes => {} }
|
|
8
|
+
@entrypoint = nil
|
|
9
|
+
@volumes = {}
|
|
10
|
+
@cwd = Dir.getwd
|
|
11
|
+
@fileLocation = fileLocation
|
|
12
|
+
@output = Tempfile.new('shuttlBuild')
|
|
13
|
+
@tar = Gem::Package::TarWriter.new(@output)
|
|
14
|
+
@dirs = [fileLocation, Dir.getwd, File.join(Dir.home, '.shuttl/definitions')]
|
|
15
|
+
if ENV.key('SHUTTL_PATH')
|
|
16
|
+
@dirs << ENV['SHUTTL_PATH'].split(":")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def findFile(name)
|
|
21
|
+
found = nil
|
|
22
|
+
@dirs.each do | dir |
|
|
23
|
+
potentialFileName = File.join(dir, name)
|
|
24
|
+
if File.exist? potentialFileName
|
|
25
|
+
if File.directory? potentialFileName
|
|
26
|
+
raise Errno::EISDIR
|
|
27
|
+
end
|
|
28
|
+
found = File.open(potentialFileName)
|
|
29
|
+
break
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
if found.nil?
|
|
33
|
+
throw "File not found: #{name}"
|
|
34
|
+
end
|
|
35
|
+
found
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def setName(name)
|
|
39
|
+
@name = name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def getName
|
|
43
|
+
@name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def addFile(localName, nameInDocker, rootName=nil)
|
|
47
|
+
if rootName.nil?
|
|
48
|
+
rootName = nameInDocker
|
|
49
|
+
end
|
|
50
|
+
begin
|
|
51
|
+
file = findFile(localName)
|
|
52
|
+
rescue Errno::EISDIR
|
|
53
|
+
@tar.mkdir(nameInDocker, 0640)
|
|
54
|
+
Dir["#{localName}/**/*"].each do |file|
|
|
55
|
+
pathInDocker = File.join(rootName, file)
|
|
56
|
+
addFile(file, pathInDocker, rootName)
|
|
57
|
+
end
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
permissions = file.is_a?(Hash) ? file[:permissions] : 0640
|
|
62
|
+
@tar.add_file(nameInDocker, permissions) do | tarFile |
|
|
63
|
+
while buffer = file.read(1024 * 1000)
|
|
64
|
+
tarFile.write(buffer)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
@tar.flush
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def create_tar(hash = {}, tap=true)
|
|
71
|
+
output = StringIO.new
|
|
72
|
+
Gem::Package::TarWriter.new(output) do |tar|
|
|
73
|
+
hash.each do |file_name, file_details|
|
|
74
|
+
permissions = file_details.is_a?(Hash) ? file_details[:permissions] : 0640
|
|
75
|
+
tar.add_file(file_name, permissions) do |tar_file|
|
|
76
|
+
content = file_details.is_a?(Hash) ? file_details[:content] : file_details
|
|
77
|
+
tar_file.write(content)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
return output.tap(&:rewind)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def gatherSettings (stage)
|
|
85
|
+
settings = @buildSettings[:settings]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def makeDockerFile (stage)
|
|
89
|
+
settings = gatherSettings(stage)
|
|
90
|
+
settingsArr = []
|
|
91
|
+
settings.each do |key, value|
|
|
92
|
+
settingsArr << "ARG #{key}=#{value}"
|
|
93
|
+
settingsArr << "ENV #{key}=${#{key}}"
|
|
94
|
+
end
|
|
95
|
+
definition = @buildSettings[:docker]
|
|
96
|
+
definition = [definition[0], ] + settingsArr + definition[1..definition.count]
|
|
97
|
+
if @entrypoint
|
|
98
|
+
definition << "ENTRYPOINT #{@entrypoint}"
|
|
99
|
+
end
|
|
100
|
+
volumes = gatherVolume stage
|
|
101
|
+
definition << "RUN echo 'echo SHUTTL IMAGE BOOTED' >> /.shuttl/run"
|
|
102
|
+
definition << "RUN echo 'bash /.shuttl/start' >> /.shuttl/run"
|
|
103
|
+
if volumes.keys.count > 0
|
|
104
|
+
definition << "VOLUME #{volumes.keys}"
|
|
105
|
+
end
|
|
106
|
+
definition.join("\n")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def gatherFiles (stage, cwd)
|
|
111
|
+
files = @buildSettings[:files]
|
|
112
|
+
files
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def makeImage (stage, cwd)
|
|
116
|
+
dockerfile = self.makeDockerFile stage
|
|
117
|
+
files = self.gatherFiles stage, cwd
|
|
118
|
+
@tar.add_file("Dockerfile", 0640) do |tar_file|
|
|
119
|
+
tar_file.write(dockerfile)
|
|
120
|
+
end
|
|
121
|
+
files.each do |key, val|
|
|
122
|
+
addFile(key, val)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build (stage, cwd, clean )
|
|
127
|
+
begin
|
|
128
|
+
makeImage stage, cwd
|
|
129
|
+
query = {}
|
|
130
|
+
query[:buildargs] = {}
|
|
131
|
+
@buildSettings[:settings].each do |key, value|
|
|
132
|
+
query[:buildargs][key] = value.to_s
|
|
133
|
+
end
|
|
134
|
+
query[:buildargs] = query[:buildargs].merge @env || Hash[]
|
|
135
|
+
puts query[:buildargs]
|
|
136
|
+
query[:buildargs] = query[:buildargs].to_json
|
|
137
|
+
if clean
|
|
138
|
+
query[:nocache] = true
|
|
139
|
+
end
|
|
140
|
+
puts query
|
|
141
|
+
Docker::Image.build_from_tar @output.tap(&:rewind), query do |v|
|
|
142
|
+
yield v
|
|
143
|
+
end
|
|
144
|
+
ensure
|
|
145
|
+
@output.close
|
|
146
|
+
@output.unlink
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def add (command)
|
|
151
|
+
@buildSettings[:docker] << command
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def fileAdd (source, destination)
|
|
155
|
+
@buildSettings[:files][source] = destination
|
|
156
|
+
add "ADD #{destination} #{destination}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def set (setting, value)
|
|
160
|
+
@buildSettings[:settings][setting] = value
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def on (name)
|
|
164
|
+
if name != @buildStage
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
yield
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def merge (other)
|
|
171
|
+
newInfo = @buildSettings.merge(other.buildSettings) do |key, old, newVal|
|
|
172
|
+
if key == :settings
|
|
173
|
+
old.merge newVal
|
|
174
|
+
elsif key == :files
|
|
175
|
+
old.merge newVal
|
|
176
|
+
elsif key == :docker
|
|
177
|
+
old.concat newVal
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
@buildSettings = newInfo
|
|
181
|
+
@volumes = other.volumes.merge @volumes
|
|
182
|
+
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def attach(localDir, volume)
|
|
186
|
+
if localDir == 'pwd'
|
|
187
|
+
localDir = @cwd
|
|
188
|
+
end
|
|
189
|
+
@volumes[volume] = localDir
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def gatherVolume(stage)
|
|
193
|
+
@volumes
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def setEnvFile(env)
|
|
197
|
+
if env.nil?
|
|
198
|
+
@env = Hash[]
|
|
199
|
+
return
|
|
200
|
+
end
|
|
201
|
+
File.open(env, 'r') do |fi|
|
|
202
|
+
@env = JSON.parse(fi.read)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def cmd (cmd)
|
|
207
|
+
add "CMD #{cmd}"
|
|
208
|
+
end
|
|
209
|
+
end
|
data/src/dsl/eval.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require_relative 'Shuttl'
|
|
2
|
+
require_relative 'buildContext'
|
|
3
|
+
|
|
4
|
+
class ShuttlDSL
|
|
5
|
+
|
|
6
|
+
def self.load(filename, stage)
|
|
7
|
+
builder = Builder.new File.dirname(filename), stage
|
|
8
|
+
dsl = Shuttl.new builder
|
|
9
|
+
dsl.instance_eval(File.read(filename), filename)
|
|
10
|
+
return builder
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
end
|
|
14
|
+
|
data/src/main.rb
ADDED
|
File without changes
|
data/src/settings.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shuttl
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yoseph Radding
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2010-04-28 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: docker-api
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 1.34.0
|
|
20
|
+
- - ">="
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: 1.34.0
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - "~>"
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: 1.34.0
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 1.34.0
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: colorize
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.8.1
|
|
40
|
+
type: :runtime
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 0.8.1
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: rubyzip
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 1.2.0
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: 1.2.0
|
|
57
|
+
type: :runtime
|
|
58
|
+
prerelease: false
|
|
59
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - "~>"
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: 1.2.0
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: 1.2.0
|
|
67
|
+
description: Shuttl builds doccker images easily
|
|
68
|
+
email: yoseph@shuttl.io
|
|
69
|
+
executables:
|
|
70
|
+
- shuttl
|
|
71
|
+
extensions: []
|
|
72
|
+
extra_rdoc_files: []
|
|
73
|
+
files:
|
|
74
|
+
- bin/shuttl
|
|
75
|
+
- src/Shuttl/Loader.rb
|
|
76
|
+
- src/Shuttl/Shuttl.rb
|
|
77
|
+
- src/commands/base.rb
|
|
78
|
+
- src/commands/build.rb
|
|
79
|
+
- src/commands/deploy.rb
|
|
80
|
+
- src/commands/info.rb
|
|
81
|
+
- src/commands/install.rb
|
|
82
|
+
- src/commands/run.rb
|
|
83
|
+
- src/commands/ssh.rb
|
|
84
|
+
- src/commands/start.rb
|
|
85
|
+
- src/commands/stop.rb
|
|
86
|
+
- src/dsl/Shuttl.rb
|
|
87
|
+
- src/dsl/buildContext.rb
|
|
88
|
+
- src/dsl/eval.rb
|
|
89
|
+
- src/main.rb
|
|
90
|
+
- src/settings.rb
|
|
91
|
+
homepage: https://github.com/shuttl-io/shuttl-cli
|
|
92
|
+
licenses:
|
|
93
|
+
- MIT
|
|
94
|
+
metadata: {}
|
|
95
|
+
post_install_message: Thanks for installing! Run shuttl install to complete the install
|
|
96
|
+
rdoc_options: []
|
|
97
|
+
require_paths:
|
|
98
|
+
- src
|
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '0'
|
|
109
|
+
requirements: []
|
|
110
|
+
rubyforge_project:
|
|
111
|
+
rubygems_version: 2.5.1
|
|
112
|
+
signing_key:
|
|
113
|
+
specification_version: 4
|
|
114
|
+
summary: Shuttl!
|
|
115
|
+
test_files: []
|