harpoon 0.0.3
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 +1 -0
- data/LICENSE.md +21 -0
- data/README.md +4 -0
- data/Rakefile +8 -0
- data/bin/harpoon +5 -0
- data/harpoon.gemspec +18 -0
- data/lib/harpoon.rb +11 -0
- data/lib/harpoon/auth.rb +101 -0
- data/lib/harpoon/client.rb +51 -0
- data/lib/harpoon/config.rb +60 -0
- data/lib/harpoon/errors.rb +8 -0
- data/lib/harpoon/runner.rb +79 -0
- data/lib/harpoon/services/s3.rb +245 -0
- data/lib/harpoon/services/test.rb +18 -0
- data/lib/harpoon/templates/harpoon.json +13 -0
- data/test/helper.rb +2 -0
- data/test/test_auth.rb +63 -0
- data/test/test_config.rb +60 -0
- data/test/test_directory/harpoon.json +3 -0
- data/test/test_directory/nested_files/harpoon.json +13 -0
- data/test/test_directory/nested_files/nested/directory/test3.txt +0 -0
- data/test/test_directory/nested_files/nested/test2.txt +0 -0
- data/test/test_directory/nested_files/test.txt +0 -0
- data/test/test_directory/test_client/harpoon.json +13 -0
- data/test/test_directory/unnested_files/harpoon.json +12 -0
- data/test/test_directory/unnested_files/nested/test2.txt +0 -0
- data/test/test_directory/unnested_files/test.txt +0 -0
- data/test/test_hosting.rb +14 -0
- data/test/test_runner.rb +34 -0
- data/test/test_services.rb +17 -0
- metadata +146 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 937896b519cb54849436445b71d22a9e8d8ba4af
|
4
|
+
data.tar.gz: 7ce38434f4b5d2c80aebe8a11fa1b76d44b8822f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 395c2a001da8362adf10d10cb49d7bda934236f00fdcb733dd09631c9b15987347fa90e5d3323939af42a3d09bde1e515b45cdd0bb08a059aefe68cb45caea3e
|
7
|
+
data.tar.gz: e58b5de1b132b71b25369087264838717fd52443c580ad109ddcfd1e9ddacc6ff7c8e82988a8350616f2e69c824da676c82d4a9f9677f469d423d8b263506f78
|
data/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
*.gem
|
data/LICENSE.md
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2013-2014 Ryan Quinn
|
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
data/Rakefile
ADDED
data/bin/harpoon
ADDED
data/harpoon.gemspec
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'harpoon'
|
3
|
+
s.version = '0.0.3'
|
4
|
+
s.date = '2014-08-20'
|
5
|
+
s.summary = "A single page app deployer for amazon s3"
|
6
|
+
s.description = "Deploy small server-less webapps to amazon s3, including buckets, dns and permissions"
|
7
|
+
s.authors = ["Ryan Quinn"]
|
8
|
+
s.email = 'ryan@mazondo.com'
|
9
|
+
s.licenses = ["MIT"]
|
10
|
+
s.files = `git ls-files -z`.split("\x0")
|
11
|
+
s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
12
|
+
s.add_dependency "thor", "~> 0.19.1"
|
13
|
+
s.add_dependency "netrc", "~> 0.7.7"
|
14
|
+
s.add_dependency "aws-sdk", "~> 1.51.0"
|
15
|
+
s.add_dependency "public_suffix", "~> 1.4.5"
|
16
|
+
s.add_dependency 'colorize', '~> 0.7.3'
|
17
|
+
s.homepage = 'http://www.getharpoon.com'
|
18
|
+
end
|
data/lib/harpoon.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
module Harpoon
|
2
|
+
require_relative "harpoon/auth"
|
3
|
+
require_relative "harpoon/client"
|
4
|
+
require_relative "harpoon/errors"
|
5
|
+
require_relative "harpoon/config"
|
6
|
+
require_relative "harpoon/runner"
|
7
|
+
|
8
|
+
# Services
|
9
|
+
require_relative "harpoon/services/test.rb"
|
10
|
+
require_relative "harpoon/services/s3.rb"
|
11
|
+
end
|
data/lib/harpoon/auth.rb
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
require "netrc"
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
module Harpoon
|
5
|
+
class Auth
|
6
|
+
attr_reader :namespace
|
7
|
+
def initialize(options = {})
|
8
|
+
@logger = options[:logger]
|
9
|
+
if options[:namespace]
|
10
|
+
@namespace = sanitize_namespace(options[:namespace])
|
11
|
+
else
|
12
|
+
@namespace = "main"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def destroy(key)
|
17
|
+
if netrc && netrc[netrc_key(key)]
|
18
|
+
netrc.delete(netrc_key(key))
|
19
|
+
netrc.save
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def set(key, value1 = nil, value2 = nil)
|
24
|
+
FileUtils.mkdir_p(File.dirname(netrc_path))
|
25
|
+
FileUtils.touch(netrc_path)
|
26
|
+
unless running_on_windows?
|
27
|
+
FileUtils.chmod(0600, netrc_path)
|
28
|
+
end
|
29
|
+
netrc[netrc_key(key)] = [netrc_nil(value1), netrc_nil(value2)]
|
30
|
+
netrc.save
|
31
|
+
end
|
32
|
+
|
33
|
+
def get(key)
|
34
|
+
if netrc
|
35
|
+
n = netrc[netrc_key(key)]
|
36
|
+
n ? n.map {|m| netrc_nil(m)} : n
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def get_or_ask(key, mes1 = "Private Key", mes2 = "Public Key")
|
41
|
+
values = get(key)
|
42
|
+
return values if values
|
43
|
+
puts "Enter your #{mes1}:"
|
44
|
+
val1 = $stdin.gets.to_s.strip
|
45
|
+
puts "Enter your #{mes2}:"
|
46
|
+
val2 = $stdin.gets.to_s.strip
|
47
|
+
set(key, val1, val2)
|
48
|
+
return [val1, val2]
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
#netrc doesn't like nil values
|
54
|
+
def netrc_nil(value = nil)
|
55
|
+
if value
|
56
|
+
if value == "nothing-here"
|
57
|
+
return nil
|
58
|
+
else
|
59
|
+
return value
|
60
|
+
end
|
61
|
+
else
|
62
|
+
return "nothing-here"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def netrc_key(key)
|
67
|
+
"harpoon-#{@namespace}-#{key}"
|
68
|
+
end
|
69
|
+
|
70
|
+
def netrc_path
|
71
|
+
default = Netrc.default_path
|
72
|
+
encrypted = default + ".gpg"
|
73
|
+
if File.exists?(encrypted)
|
74
|
+
encrypted
|
75
|
+
else
|
76
|
+
default
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def netrc
|
81
|
+
@netrc ||= begin
|
82
|
+
File.exists?(netrc_path) && Netrc.read(netrc_path)
|
83
|
+
rescue => error
|
84
|
+
if error.message =~ /^Permission bits for/
|
85
|
+
perm = File.stat(netrc_path).mode & 0777
|
86
|
+
abort("Permissions #{perm} for '#{netrc_path}' are too open. You should run `chmod 0600 #{netrc_path}` so that your credentials are NOT accessible by others.")
|
87
|
+
else
|
88
|
+
raise error
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def running_on_windows?
|
94
|
+
RUBY_PLATFORM =~ /mswin32|mingw32/
|
95
|
+
end
|
96
|
+
|
97
|
+
def sanitize_namespace(n)
|
98
|
+
n.gsub(/[^a-zA-Z0-9\-]/, "")
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require "thor"
|
3
|
+
|
4
|
+
module Harpoon
|
5
|
+
class Client < Thor
|
6
|
+
class_option :config, :type => :string
|
7
|
+
class_option :log_level, :type => :string
|
8
|
+
|
9
|
+
desc "init", "Initializes a config file in current directory"
|
10
|
+
def init
|
11
|
+
#initialize a config file in the current directory
|
12
|
+
begin
|
13
|
+
Harpoon::Config.create(Dir.pwd)
|
14
|
+
rescue Harpoon::Errors::AlreadyInitialized => e
|
15
|
+
puts e.message
|
16
|
+
else
|
17
|
+
puts "Harpoon has been initialized"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
desc "setup", "Setup the current app"
|
22
|
+
def setup
|
23
|
+
runner = Harpoon::Runner.new(options)
|
24
|
+
runner.setup
|
25
|
+
end
|
26
|
+
|
27
|
+
desc "deploy", "Deploys the current app"
|
28
|
+
def deploy
|
29
|
+
runner = Harpoon::Runner.new(options)
|
30
|
+
runner.deploy
|
31
|
+
end
|
32
|
+
|
33
|
+
desc "doctor", "Check the health of the current deploy strategy"
|
34
|
+
def doctor
|
35
|
+
runner = Harpoon::Runner.new(options)
|
36
|
+
runner.doctor
|
37
|
+
end
|
38
|
+
|
39
|
+
desc "list", "List available rollbacks"
|
40
|
+
def list
|
41
|
+
runner = Harpoon::Runner.new(options)
|
42
|
+
runner.list
|
43
|
+
end
|
44
|
+
|
45
|
+
desc "rollback", "Rollback to previous release"
|
46
|
+
def rollback
|
47
|
+
runner = Harpoon::Runner.new(options)
|
48
|
+
runner.rollback
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require "json"
|
2
|
+
require "fileutils"
|
3
|
+
|
4
|
+
module Harpoon
|
5
|
+
class Config
|
6
|
+
# Checks if a config exists at a given path
|
7
|
+
def self.exists?(path)
|
8
|
+
path = full_path(path, false)
|
9
|
+
File.exists?(path)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Returns the full path of a config given a directory.
|
13
|
+
# By default this raises an alert if the config doesn't exist.
|
14
|
+
def self.full_path(path, must_exist = true)
|
15
|
+
path = File.expand_path(path)
|
16
|
+
if File.directory?(path)
|
17
|
+
path = File.join(path, "harpoon.json")
|
18
|
+
end
|
19
|
+
raise Harpoon::Errors::InvalidConfigLocation, "No config located at #{path}" if must_exist && !File.exists?(path)
|
20
|
+
path
|
21
|
+
end
|
22
|
+
|
23
|
+
# Create a config at a given path
|
24
|
+
def self.create(path)
|
25
|
+
path = full_path(path, false)
|
26
|
+
if File.exists?(path)
|
27
|
+
raise Harpoon::Errors::AlreadyInitialized, "Harpoon has already been initialized, see #{path}"
|
28
|
+
end
|
29
|
+
FileUtils.copy_file File.join(File.dirname(__FILE__), "templates", "harpoon.json"), path
|
30
|
+
end
|
31
|
+
|
32
|
+
# Load a config file from a given path
|
33
|
+
# Returns a new config object
|
34
|
+
def self.read(path = nil, logger = nil)
|
35
|
+
path = full_path(path)
|
36
|
+
if File.exists? path
|
37
|
+
data = JSON.parse(IO.read(path))
|
38
|
+
directory = [File.dirname(path), data["directory"]].select {|m| m && m != ""}
|
39
|
+
data["directory"] = File.join(directory)
|
40
|
+
new data, logger
|
41
|
+
else
|
42
|
+
raise Harpoon::Errors::InvalidConfigLocation, "Specified config doesn't exist, please create one"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
attr_reader :files
|
47
|
+
|
48
|
+
# Initialize a new config object with the data loaded
|
49
|
+
def initialize(data = {}, logger = nil)
|
50
|
+
@config = data
|
51
|
+
@logger = logger
|
52
|
+
@files = Dir.glob(File.join(@config["directory"], "**", "*")).select {|f| !File.directory?(f) && File.basename(f) != "harpoon.json"} if @config["directory"]
|
53
|
+
end
|
54
|
+
|
55
|
+
# Check for the configuration item
|
56
|
+
def method_missing(method, *args)
|
57
|
+
@config[method.to_s]
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require "logger"
|
2
|
+
require "colorize"
|
3
|
+
|
4
|
+
module Harpoon
|
5
|
+
class Runner
|
6
|
+
def initialize(options)
|
7
|
+
@logger = load_logger(options[:log_level])
|
8
|
+
@config = Harpoon::Config.read(options[:config] || Dir.pwd, @logger)
|
9
|
+
@auth = load_auth
|
10
|
+
@service = load_host
|
11
|
+
end
|
12
|
+
|
13
|
+
def method_missing(method, *args)
|
14
|
+
#don't know about this here, must be for the service
|
15
|
+
@service.send(method, *args)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def load_auth
|
21
|
+
if @config.auth_namespace
|
22
|
+
return Harpoon::Auth.new({namespace: @config.auth_namespace, logger: @logger})
|
23
|
+
else
|
24
|
+
return Harpoon::Auth.new({logger: @logger})
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def load_host
|
29
|
+
if @config.hosting
|
30
|
+
begin
|
31
|
+
return Harpoon::Services.const_get(@config.hosting.capitalize).new(@config, @auth, @logger)
|
32
|
+
rescue NameError => e
|
33
|
+
raise Harpoon::Errors::InvalidConfiguration, "Unknown Hosting Service: #{@config.hosting}"
|
34
|
+
end
|
35
|
+
else
|
36
|
+
raise Harpoon::Errors::InvalidConfiguration, "Hosting parameter is required"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def load_logger(log_level = "info")
|
41
|
+
log_level ||= "info"
|
42
|
+
logger = Logger.new(STDOUT)
|
43
|
+
|
44
|
+
#set log level
|
45
|
+
case log_level.downcase
|
46
|
+
when "debug"
|
47
|
+
logger.level = Logger::DEBUG
|
48
|
+
when "info"
|
49
|
+
logger.level = Logger::INFO
|
50
|
+
when "warn"
|
51
|
+
logger.level = Logger::WARN
|
52
|
+
when "error"
|
53
|
+
logger.level = Logger::ERROR
|
54
|
+
when "fatal"
|
55
|
+
logger.level = Logger::FATAL
|
56
|
+
end
|
57
|
+
|
58
|
+
#set log formatter
|
59
|
+
logger.formatter = proc do |severity, datetime, progname, msg|
|
60
|
+
case severity.to_s.downcase
|
61
|
+
when "debug"
|
62
|
+
"DEBUG: #{msg}\n".colorize(:light_blue)
|
63
|
+
when "info"
|
64
|
+
"#{msg}\n".colorize(:gray)
|
65
|
+
when "warn"
|
66
|
+
"#{msg}\n".colorize(:yellow)
|
67
|
+
when "error"
|
68
|
+
"#{msg}\n".colorize(:orange)
|
69
|
+
when "fatal"
|
70
|
+
"#{msg}\n".colorize(:red)
|
71
|
+
else
|
72
|
+
"#{severity} - #{msg}\n"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
logger
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,245 @@
|
|
1
|
+
require "aws-sdk"
|
2
|
+
require "uri"
|
3
|
+
require "public_suffix"
|
4
|
+
require "pathname"
|
5
|
+
|
6
|
+
module Harpoon
|
7
|
+
module Services
|
8
|
+
class S3
|
9
|
+
def initialize(config, auth, logger)
|
10
|
+
# Store what we're given for later
|
11
|
+
@config = config
|
12
|
+
@auth = auth
|
13
|
+
@logger = logger
|
14
|
+
|
15
|
+
# Ask for the users credentials
|
16
|
+
@credentials = @auth.get_or_ask("s3", "Key", "Secret")
|
17
|
+
|
18
|
+
if config.hosting_options && config.hosting_options["region"]
|
19
|
+
region = config.hosting_options["region"]
|
20
|
+
else
|
21
|
+
region = "us-west-2"
|
22
|
+
end
|
23
|
+
|
24
|
+
AWS.config(access_key_id: @credentials[0], secret_access_key: @credentials[1], region: region)
|
25
|
+
|
26
|
+
# setup amazon interfaces
|
27
|
+
@s3 = AWS::S3.new
|
28
|
+
@r53 = AWS::Route53.new
|
29
|
+
end
|
30
|
+
|
31
|
+
def setup
|
32
|
+
if @config.domain
|
33
|
+
#we have domain info, so let's make sure it's setup for it
|
34
|
+
if @config.domain["primary"]
|
35
|
+
#primary domain detected, let's make sure it exists
|
36
|
+
bucket = setup_bucket(@config.domain["primary"])
|
37
|
+
#setup bucket to server webpages
|
38
|
+
@logger.info "Setting primary domain as website"
|
39
|
+
bucket.configure_website
|
40
|
+
#setup ACL
|
41
|
+
@logger.info "Setting bucket policy"
|
42
|
+
policy = AWS::S3::Policy.new
|
43
|
+
policy.allow(
|
44
|
+
actions: ['s3:GetObject'],
|
45
|
+
resources: [bucket.objects],
|
46
|
+
principals: :any
|
47
|
+
)
|
48
|
+
|
49
|
+
@logger.debug policy.to_json
|
50
|
+
|
51
|
+
bucket.policy = policy
|
52
|
+
|
53
|
+
|
54
|
+
history = setup_bucket(rollback_bucket(@config.domain["primary"]))
|
55
|
+
@logger.info "Created rollback bucket"
|
56
|
+
|
57
|
+
setup_dns_alias(@config.domain["primary"], bucket)
|
58
|
+
|
59
|
+
|
60
|
+
if @config.domain["forwarded"]
|
61
|
+
#we also want to forward some domains
|
62
|
+
#make sure we have an array
|
63
|
+
#TODO : Move all of this nonsense to the config object, it should be validating this stuff
|
64
|
+
forwarded = @config.domain["forwarded"].is_a?(Array) ? @config.domain["forwarded"] : [@config.domain["forwarded"]]
|
65
|
+
forwarded.each do |f|
|
66
|
+
bucket = setup_bucket(f)
|
67
|
+
@logger.info "Seting up redirect to primary"
|
68
|
+
cw = AWS::S3::WebsiteConfiguration.new({redirect_all_requests_to: {host_name: @config.domain["primary"]}})
|
69
|
+
bucket.website_configuration = cw
|
70
|
+
setup_dns_alias(f, bucket)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# print out DNS settings
|
75
|
+
print_dns_settings(@config.domain["primary"])
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def deploy
|
81
|
+
raise Harpoon::Errors::InvalidConfiguration, "Missing list of files" unless @config.files && @config.directory && @config.domain["primary"]
|
82
|
+
move_existing_to_history!
|
83
|
+
current_bucket = @s3.buckets[@config.domain["primary"]]
|
84
|
+
raise Harpoon::Errors::MissingSetup, "Required s3 buckets are not created, consider running harpoon setup first" unless current_bucket.exists?
|
85
|
+
@logger.info "Writing files to s3"
|
86
|
+
@config.files.each do |f|
|
87
|
+
@logger.debug "Path: #{f}"
|
88
|
+
relative_path = Pathname.new(f).relative_path_from(Pathname.new(@config.directory)).to_s
|
89
|
+
@logger.debug "s3 key: #{relative_path}"
|
90
|
+
current_bucket.objects[relative_path].write(Pathname.new(f))
|
91
|
+
end
|
92
|
+
@logger.info "Deploy complete"
|
93
|
+
end
|
94
|
+
|
95
|
+
def list
|
96
|
+
@logger.info "The following rollbacks are available:"
|
97
|
+
if @config.domain && @config.domain["primary"]
|
98
|
+
tree = @s3.buckets[rollback_bucket(@config.domain["primary"])].as_tree
|
99
|
+
rollbacks = tree.children.collect {|i| i.prefix.gsub(/\/$/, "").to_i }
|
100
|
+
rollbacks.sort!.reverse!
|
101
|
+
rollbacks.each_with_index do |r, index|
|
102
|
+
@logger.info Time.at(r).strftime("#{index + 1} - %F %r")
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def doctor
|
108
|
+
# check configuration
|
109
|
+
if @config.domain
|
110
|
+
if @config.domain["primary"]
|
111
|
+
@logger.info "Primary Domain: #{@config.domain["primary"]}"
|
112
|
+
else
|
113
|
+
@logger.fatal "Missing Primary Domain"
|
114
|
+
exit
|
115
|
+
end
|
116
|
+
else
|
117
|
+
@logger.fatal "Missing Domain Configuration"
|
118
|
+
exit
|
119
|
+
end
|
120
|
+
# check IAM permissions
|
121
|
+
# check buckets exist
|
122
|
+
primary_bucket = @s3.buckets[@config.domain["primary"]]
|
123
|
+
if primary_bucket.exists?
|
124
|
+
@logger.info "Primary bucket exists"
|
125
|
+
else
|
126
|
+
@logger.fatal "Missing Primary domain bucket"
|
127
|
+
end
|
128
|
+
# check domain setup
|
129
|
+
# print DNS settings
|
130
|
+
print_dns_settings(@config.domain["primary"])
|
131
|
+
end
|
132
|
+
|
133
|
+
def rollback
|
134
|
+
@logger.info "Not yet implemented!"
|
135
|
+
@logger.info "But don't worry, your rollbacks are safely stored in #{rollback_bucket(@config.domain["primary"])}"
|
136
|
+
self.list
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def setup_dns_alias(domain, bucket)
|
142
|
+
@logger.debug "Setup Domain Alias for #{domain}"
|
143
|
+
#extract root domain
|
144
|
+
rdomain = root_domain(domain)
|
145
|
+
@logger.debug "Root Domain: #{rdomain}"
|
146
|
+
#add that dot
|
147
|
+
rdomain += "." unless rdomain.end_with?(".")
|
148
|
+
domain += "." unless domain.end_with?(".")
|
149
|
+
@logger.debug "Post Dot root: #{rdomain}"
|
150
|
+
@logger.debug "Post Dot domain: #{domain}"
|
151
|
+
|
152
|
+
|
153
|
+
#ensure we have a hosted zone
|
154
|
+
hosted_zone = @r53.hosted_zones.find {|h| h.name == rdomain}
|
155
|
+
hosted_zone = @r53.hosted_zones.create(rdomain, {comment: "Created By Harpoon"}) if !hosted_zone
|
156
|
+
|
157
|
+
record = hosted_zone.rrsets[domain, "A"]
|
158
|
+
|
159
|
+
dns_alias, zone_id = alias_and_zone_id(bucket.location_constraint)
|
160
|
+
@logger.debug "Alias: #{dns_alias}, Zone: #{zone_id}"
|
161
|
+
|
162
|
+
record = hosted_zone.rrsets.create(domain, "A", alias_target: {dns_name: dns_alias, hosted_zone_id: zone_id, evaluate_target_health: false}) unless record.exists?
|
163
|
+
@logger.info "Created Host Record: #{record.name}, #{record.type}"
|
164
|
+
end
|
165
|
+
|
166
|
+
def setup_bucket(bucket_name)
|
167
|
+
@logger.info "Creating bucket: #{bucket_name}"
|
168
|
+
bucket = @s3.buckets[bucket_name]
|
169
|
+
unless bucket.exists?
|
170
|
+
bucket = @s3.buckets.create(bucket_name)
|
171
|
+
end
|
172
|
+
bucket
|
173
|
+
end
|
174
|
+
|
175
|
+
def root_domain(domain)
|
176
|
+
#pull out the host if we have a full url
|
177
|
+
domain = URI.parse(domain).host if domain.start_with?("http")
|
178
|
+
PublicSuffix.parse(domain).domain
|
179
|
+
end
|
180
|
+
|
181
|
+
def alias_and_zone_id(constraint)
|
182
|
+
#taken from: http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
|
183
|
+
case constraint
|
184
|
+
when "us-east-1"
|
185
|
+
return "s3-website-us-east-1.amazonaws.com.", "Z3AQBSTGFYJSTF"
|
186
|
+
when "us-west-2"
|
187
|
+
return "s3-website-us-west-2.amazonaws.com.", "Z3BJ6K6RIION7M"
|
188
|
+
when "us-west-1"
|
189
|
+
return "s3-website-us-west-1.amazonaws.com.", "Z2F56UZL2M1ACD"
|
190
|
+
when "eu-west-1"
|
191
|
+
return "s3-website-eu-west-1.amazonaws.com.", "Z1BKCTXD74EZPE"
|
192
|
+
when "ap-southeast-1"
|
193
|
+
return "s3-website-ap-southeast-1.amazonaws.com.", "Z3O0J2DXBE1FTB"
|
194
|
+
when "ap-southeast-2"
|
195
|
+
return "s3-website-ap-southeast-2.amazonaws.com.", "Z1WCIGYICN2BYD"
|
196
|
+
when "ap-northeast-1"
|
197
|
+
return "s3-website-ap-northeast-1.amazonaws.com.", "Z2M4EHUR26P7ZW"
|
198
|
+
when "sa-east-1"
|
199
|
+
return "s3-website-sa-east-1.amazonaws.com.", "Z7KQH4QJS55SO"
|
200
|
+
when "us-gov-west-1"
|
201
|
+
return "s3-website-us-gov-west-1.amazonaws.com.", "Z31GFT0UA1I2HV"
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
def print_dns_settings(domain)
|
206
|
+
@logger.debug "Print DNS Settings for: #{domain}"
|
207
|
+
rdomain = "#{root_domain(domain)}."
|
208
|
+
@logger.debug "Root Domain: #{rdomain}"
|
209
|
+
@logger.warn "=============================="
|
210
|
+
@logger.warn "Please forward to the following DNS:"
|
211
|
+
hosted_zone = @r53.hosted_zones.find {|h| h.name == rdomain}
|
212
|
+
hosted_zone.rrsets[rdomain, "NS"].resource_records.each do |r|
|
213
|
+
@logger.warn r[:value]
|
214
|
+
end
|
215
|
+
@logger.warn "=============================="
|
216
|
+
end
|
217
|
+
|
218
|
+
def rollback_bucket(domain)
|
219
|
+
"#{domain}-history"
|
220
|
+
end
|
221
|
+
|
222
|
+
def move_existing_to_history!
|
223
|
+
raise Harpoon::Errors::InvalidConfiguration, "Must have a primary domain defined" unless @config.domain["primary"]
|
224
|
+
@logger.info "Moving existing deploy to history"
|
225
|
+
current = @s3.buckets[@config.domain["primary"]]
|
226
|
+
history = @s3.buckets[rollback_bucket(@config.domain["primary"])]
|
227
|
+
raise Harpoon::Errors::MissingSetup, "The expected buckets are not yet created, please try running harpoon setup" unless current.exists? && history.exists?
|
228
|
+
|
229
|
+
current_date = Time.now.to_i
|
230
|
+
#iterate over current bucket objects and prefix them with timestamp, move to history bucket
|
231
|
+
current.objects.each do |o|
|
232
|
+
s3_key = File.join(current_date.to_s, o.key)
|
233
|
+
@logger.debug "Original Key: #{o.key}"
|
234
|
+
@logger.debug "History Key: #{s3_key}"
|
235
|
+
@logger.debug "Metadata: #{o.metadata.to_h.inspect}"
|
236
|
+
history.objects[s3_key].write(o.read, {metadata: o.metadata})
|
237
|
+
end
|
238
|
+
@logger.debug "Moved to history, deleting files from current bucket"
|
239
|
+
#delete the current objects
|
240
|
+
current.objects.delete_all
|
241
|
+
@logger.debug "Files deleted"
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Harpoon
|
2
|
+
module Services
|
3
|
+
class Test
|
4
|
+
attr_accessor :config, :requests
|
5
|
+
|
6
|
+
def initialize(config = nil, auth = nil, logger = nil)
|
7
|
+
@auth = auth
|
8
|
+
@config = config
|
9
|
+
@requests = []
|
10
|
+
@logger = logger
|
11
|
+
end
|
12
|
+
|
13
|
+
def method_missing(method, *args)
|
14
|
+
@requests.push [method, args]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/test/helper.rb
ADDED
data/test/test_auth.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
describe "Auth token" do
|
4
|
+
|
5
|
+
it "Should let me initialize a namespace" do
|
6
|
+
auth = Harpoon::Auth.new
|
7
|
+
assert_equal "main", auth.namespace, "Should default to main namespace"
|
8
|
+
|
9
|
+
auth = Harpoon::Auth.new({namespace: "test"})
|
10
|
+
assert_equal "test", auth.namespace, "Should have set the namespace"
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should let me store and retrieve namespaced auth params" do
|
14
|
+
auth = Harpoon::Auth.new({namespace: "test"})
|
15
|
+
auth2 = Harpoon::Auth.new({namespace: "test2"})
|
16
|
+
|
17
|
+
#delete if already exists
|
18
|
+
auth.destroy "test-host"
|
19
|
+
auth2.destroy "test-host"
|
20
|
+
|
21
|
+
assert !auth.get("test-host"), "Should have destroyed values"
|
22
|
+
assert !auth2.get("test-host"), "Should have destroyed values"
|
23
|
+
|
24
|
+
auth.set "test-host", "key", "secret"
|
25
|
+
auth2.set "test-host", "key2", "secret2"
|
26
|
+
|
27
|
+
assert_equal ["key", "secret"], auth.get("test-host"), "Should have set Auth1"
|
28
|
+
assert_equal ["key2", "secret2"], auth2.get("test-host"), "Should have set Auth2"
|
29
|
+
end
|
30
|
+
|
31
|
+
it "Should be able to handle single keys" do
|
32
|
+
auth = Harpoon::Auth.new({namespace: "test"})
|
33
|
+
|
34
|
+
# destroy if it exists
|
35
|
+
auth.destroy "test-host"
|
36
|
+
|
37
|
+
auth.set "test-host", "key"
|
38
|
+
|
39
|
+
assert_equal ["key", nil], auth.get("test-host"), "Should have been able to handle a single key"
|
40
|
+
end
|
41
|
+
|
42
|
+
it "Should understand how to get or ask" do
|
43
|
+
auth = Harpoon::Auth.new({namespace: "test"})
|
44
|
+
auth.destroy "test-host"
|
45
|
+
|
46
|
+
assert !auth.get("test-host"), "Should have destroyed values"
|
47
|
+
|
48
|
+
auth.get_or_ask "test-host", "enter 1", "enter 2"
|
49
|
+
|
50
|
+
assert_equal 1, auth.get("test-host")[0].to_i, "Should have stored it correctly"
|
51
|
+
assert_equal 2, auth.get("test-host")[1].to_i, "Should have stored it correctly"
|
52
|
+
end
|
53
|
+
|
54
|
+
it "Should know how to handle weird namespaces" do
|
55
|
+
auth = Harpoon::Auth.new({namespace: "a.Z ?what NOW-2"})
|
56
|
+
assert_equal "aZwhatNOW-2", auth.namespace, "Should have cleaned up the name"
|
57
|
+
end
|
58
|
+
|
59
|
+
it "Should be able to be given a logger" do
|
60
|
+
auth = Harpoon::Auth.new({logger: Logger.new(STDOUT)})
|
61
|
+
assert_equal Logger, auth.instance_eval {@logger.class}, "Should have stored the logger"
|
62
|
+
end
|
63
|
+
end
|
data/test/test_config.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
describe "Config File" do
|
4
|
+
|
5
|
+
it "Should raise an error for missing config" do
|
6
|
+
assert_raises Harpoon::Errors::InvalidConfigLocation do
|
7
|
+
config = Harpoon::Config.read("test_directory/missing.json")
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
it "Should create a config where I tell it to" do
|
12
|
+
temp_file = Harpoon::Config.full_path("test/test_directory/test_create", false)
|
13
|
+
#does the temp file exist? if so, delete it
|
14
|
+
if Harpoon::Config.exists?(temp_file)
|
15
|
+
File.delete(temp_file)
|
16
|
+
end
|
17
|
+
|
18
|
+
assert !File.exists?(temp_file), "Should have deleted old config"
|
19
|
+
|
20
|
+
#create file
|
21
|
+
Harpoon::Config.create(temp_file)
|
22
|
+
|
23
|
+
assert File.exists?(temp_file), "Should have created config file"
|
24
|
+
File.delete(temp_file)
|
25
|
+
end
|
26
|
+
|
27
|
+
it "Should know how to parse a config file" do
|
28
|
+
config = Harpoon::Config.read("test/test_directory")
|
29
|
+
assert_equal "Ryan", config.name, "Should have read the config file"
|
30
|
+
assert_equal nil, config.other_value, "Should not have another value"
|
31
|
+
end
|
32
|
+
|
33
|
+
it "Should be able to tell me that a harpoon config exists" do
|
34
|
+
assert Harpoon::Config::exists?("test/test_directory")
|
35
|
+
end
|
36
|
+
|
37
|
+
it "Should let me ask for the full path a config file" do
|
38
|
+
assert_equal File.join(Dir.pwd, "test", "test_directory", "harpoon.json"), Harpoon::Config.full_path("test/test_directory"), "Should return the full file path of a config file"
|
39
|
+
end
|
40
|
+
|
41
|
+
it "Should be able to be given a logger" do
|
42
|
+
config = Harpoon::Config.read("test/test_directory", Logger.new(STDOUT))
|
43
|
+
assert_equal Logger, config.instance_eval {@logger.class}, "Should have stored the logger"
|
44
|
+
end
|
45
|
+
|
46
|
+
it "Should expect and sanitize input" do
|
47
|
+
skip "Not implemented"
|
48
|
+
end
|
49
|
+
|
50
|
+
it "Should provide a list of files for the services" do
|
51
|
+
nested = Harpoon::Config.read("test/test_directory/nested_files")
|
52
|
+
unnested = Harpoon::Config.read("test/test_directory/unnested_files")
|
53
|
+
|
54
|
+
assert_equal 1, nested.files.length, "Should have only found 1 file"
|
55
|
+
assert_equal File.join(Dir.pwd, "test", "test_directory", "nested_files", "nested", "directory", "test3.txt"), nested.files.first, "Should have found the correct file"
|
56
|
+
|
57
|
+
assert_equal 2, unnested.files.length, "Should have found 2 files"
|
58
|
+
assert_equal [File.join(Dir.pwd, "test", "test_directory", "unnested_files", "nested", "test2.txt"), File.join(Dir.pwd, "test", "test_directory", "unnested_files", "test.txt")], unnested.files, "Should have found the right files"
|
59
|
+
end
|
60
|
+
end
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
describe "Test Hosting Module" do
|
4
|
+
before do
|
5
|
+
@hosting = Harpoon::Services::Test.new
|
6
|
+
end
|
7
|
+
|
8
|
+
it "Should let me make requests" do
|
9
|
+
@hosting.deploy "options", "go", "here"
|
10
|
+
assert_equal :deploy, @hosting.requests[0][0]
|
11
|
+
assert_equal ["options", "go", "here"], @hosting.requests[0][1]
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
data/test/test_runner.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
require "helper"
|
2
|
+
describe "Runner" do
|
3
|
+
before do
|
4
|
+
@runner = Harpoon::Runner.new({config: "test/test_directory/test_client"})
|
5
|
+
end
|
6
|
+
|
7
|
+
it "Should load the config from the config option" do
|
8
|
+
assert_equal "test-app", @runner.instance_eval {@config.name}, "Should have loaded the config file"
|
9
|
+
end
|
10
|
+
|
11
|
+
it "Should have loaded the service from the config" do
|
12
|
+
assert_equal Harpoon::Services::Test, @runner.instance_eval {@service.class}, "Should have set the service from the config file"
|
13
|
+
end
|
14
|
+
|
15
|
+
it "Should load the auth namespace from the service" do
|
16
|
+
assert_equal "test-namespace", @runner.instance_eval {@auth.namespace}, "Should have set the namespace correctly"
|
17
|
+
end
|
18
|
+
|
19
|
+
it "Should be passing in the configuration and auth to the service" do
|
20
|
+
assert_equal @runner.instance_eval {@auth}, @runner.instance_eval {@service.instance_eval {@auth}}, "Should have gotten the right auth"
|
21
|
+
assert_equal @runner.instance_eval {@config}, @runner.instance_eval {@service.instance_eval {@config}}, "Should have gotten the right config"
|
22
|
+
end
|
23
|
+
|
24
|
+
it "Should pass any unknown commands to the service" do
|
25
|
+
@runner.deploy
|
26
|
+
assert_equal :deploy, @runner.instance_eval {@service.requests[0][0]}, "Should have run deploy on the service"
|
27
|
+
end
|
28
|
+
|
29
|
+
it "Should pass a default logger to everyone" do
|
30
|
+
assert_equal Logger, @runner.instance_eval {@service.instance_eval {@logger.class}}, "Should have passed a logger to the service"
|
31
|
+
assert_equal Logger, @runner.instance_eval {@auth.instance_eval {@logger.class}}, "Should have passed a logger to the auth"
|
32
|
+
assert_equal Logger, @runner.instance_eval {@config.instance_eval {@logger.class}}, "Should have passed a logger to the config"
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
describe "Test all services" do
|
4
|
+
|
5
|
+
it "Should test all services to make sure they have the minimum" do
|
6
|
+
#load all the services we know about and iterate over them, making sure they have the required
|
7
|
+
# functions
|
8
|
+
Harpoon::Services.constants.each do |c|
|
9
|
+
next if c.to_s == "Test" #skip the test
|
10
|
+
m = Kernel.const_get("Harpoon").const_get("Services").const_get(c).instance_methods
|
11
|
+
assert_includes m, :deploy, "Should include a deploy method"
|
12
|
+
assert_includes m, :doctor, "Should include a doctor method"
|
13
|
+
assert_includes m, :rollback, "Should include a rollback method"
|
14
|
+
assert_includes m, :list, "Should include a list method"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
metadata
ADDED
@@ -0,0 +1,146 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: harpoon
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.3
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ryan Quinn
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-08-20 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: thor
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ~>
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.19.1
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ~>
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.19.1
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: netrc
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ~>
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.7.7
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ~>
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.7.7
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: aws-sdk
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ~>
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 1.51.0
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ~>
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 1.51.0
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: public_suffix
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ~>
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 1.4.5
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ~>
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 1.4.5
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: colorize
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ~>
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 0.7.3
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ~>
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 0.7.3
|
83
|
+
description: Deploy small server-less webapps to amazon s3, including buckets, dns
|
84
|
+
and permissions
|
85
|
+
email: ryan@mazondo.com
|
86
|
+
executables:
|
87
|
+
- harpoon
|
88
|
+
extensions: []
|
89
|
+
extra_rdoc_files: []
|
90
|
+
files:
|
91
|
+
- .gitignore
|
92
|
+
- LICENSE.md
|
93
|
+
- README.md
|
94
|
+
- Rakefile
|
95
|
+
- bin/harpoon
|
96
|
+
- harpoon-0.0.1.gem
|
97
|
+
- harpoon.gemspec
|
98
|
+
- lib/harpoon.rb
|
99
|
+
- lib/harpoon/auth.rb
|
100
|
+
- lib/harpoon/client.rb
|
101
|
+
- lib/harpoon/config.rb
|
102
|
+
- lib/harpoon/errors.rb
|
103
|
+
- lib/harpoon/runner.rb
|
104
|
+
- lib/harpoon/services/s3.rb
|
105
|
+
- lib/harpoon/services/test.rb
|
106
|
+
- lib/harpoon/templates/harpoon.json
|
107
|
+
- test/helper.rb
|
108
|
+
- test/test_auth.rb
|
109
|
+
- test/test_config.rb
|
110
|
+
- test/test_directory/harpoon.json
|
111
|
+
- test/test_directory/nested_files/harpoon.json
|
112
|
+
- test/test_directory/nested_files/nested/directory/test3.txt
|
113
|
+
- test/test_directory/nested_files/nested/test2.txt
|
114
|
+
- test/test_directory/nested_files/test.txt
|
115
|
+
- test/test_directory/test_client/harpoon.json
|
116
|
+
- test/test_directory/unnested_files/harpoon.json
|
117
|
+
- test/test_directory/unnested_files/nested/test2.txt
|
118
|
+
- test/test_directory/unnested_files/test.txt
|
119
|
+
- test/test_hosting.rb
|
120
|
+
- test/test_runner.rb
|
121
|
+
- test/test_services.rb
|
122
|
+
homepage: http://www.getharpoon.com
|
123
|
+
licenses:
|
124
|
+
- MIT
|
125
|
+
metadata: {}
|
126
|
+
post_install_message:
|
127
|
+
rdoc_options: []
|
128
|
+
require_paths:
|
129
|
+
- lib
|
130
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
131
|
+
requirements:
|
132
|
+
- - '>='
|
133
|
+
- !ruby/object:Gem::Version
|
134
|
+
version: '0'
|
135
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
136
|
+
requirements:
|
137
|
+
- - '>='
|
138
|
+
- !ruby/object:Gem::Version
|
139
|
+
version: '0'
|
140
|
+
requirements: []
|
141
|
+
rubyforge_project:
|
142
|
+
rubygems_version: 2.1.11
|
143
|
+
signing_key:
|
144
|
+
specification_version: 4
|
145
|
+
summary: A single page app deployer for amazon s3
|
146
|
+
test_files: []
|