ol-whisk_deploy 0.6.25
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +292 -0
- data/MIT-LICENSE +20 -0
- data/README.integration_specs +24 -0
- data/README.markdown +832 -0
- data/Rakefile +105 -0
- data/VERSION +1 -0
- data/WHY.txt +45 -0
- data/bin/wd +61 -0
- data/bin/wd_role +42 -0
- data/examples/deploy-configs.yml +13 -0
- data/examples/deploy-local.yml +4 -0
- data/examples/deploy-multiple-remotes.yml +26 -0
- data/examples/deploy-staging.yml +8 -0
- data/examples/deploy.rake +11 -0
- data/examples/deploy.yml +16 -0
- data/init.rb +1 -0
- data/install.rb +5 -0
- data/lib/whiskey_disk.rb +327 -0
- data/lib/whiskey_disk/config.rb +127 -0
- data/lib/whiskey_disk/config/abstract_filter.rb +19 -0
- data/lib/whiskey_disk/config/filter.rb +48 -0
- data/lib/whiskey_disk/config/filters/add_environment_name_filter.rb +11 -0
- data/lib/whiskey_disk/config/filters/add_project_name_filter.rb +11 -0
- data/lib/whiskey_disk/config/filters/check_for_duplicate_domains_filter.rb +21 -0
- data/lib/whiskey_disk/config/filters/convert_role_strings_to_list_filter.rb +20 -0
- data/lib/whiskey_disk/config/filters/default_config_target_filter.rb +12 -0
- data/lib/whiskey_disk/config/filters/default_domain_filter.rb +12 -0
- data/lib/whiskey_disk/config/filters/drop_empty_domain_roles_filter.rb +32 -0
- data/lib/whiskey_disk/config/filters/environment_scope_filter.rb +20 -0
- data/lib/whiskey_disk/config/filters/hashify_domain_entries_filter.rb +29 -0
- data/lib/whiskey_disk/config/filters/localize_domains_filter.rb +24 -0
- data/lib/whiskey_disk/config/filters/modules/scope_helper.rb +11 -0
- data/lib/whiskey_disk/config/filters/normalize_ssh_options_filter.rb +29 -0
- data/lib/whiskey_disk/config/filters/project_scope_filter.rb +34 -0
- data/lib/whiskey_disk/config/filters/select_project_and_environment_filter.rb +12 -0
- data/lib/whiskey_disk/config/filters/stringify_hash_keys_filter.rb +25 -0
- data/lib/whiskey_disk/helpers.rb +50 -0
- data/lib/whiskey_disk/rake.rb +47 -0
- data/scenarios/git_repositories/config.git/HEAD +1 -0
- data/scenarios/git_repositories/config.git/config +5 -0
- data/scenarios/git_repositories/config.git/description +1 -0
- data/scenarios/git_repositories/config.git/git-daemon-export-ok +0 -0
- data/scenarios/git_repositories/config.git/hooks/applypatch-msg.sample +15 -0
- data/scenarios/git_repositories/config.git/hooks/commit-msg.sample +24 -0
- data/scenarios/git_repositories/config.git/hooks/post-commit.sample +8 -0
- data/scenarios/git_repositories/config.git/hooks/post-receive.sample +15 -0
- data/scenarios/git_repositories/config.git/hooks/post-update.sample +8 -0
- data/scenarios/git_repositories/config.git/hooks/pre-applypatch.sample +14 -0
- data/scenarios/git_repositories/config.git/hooks/pre-commit.sample +46 -0
- data/scenarios/git_repositories/config.git/hooks/pre-rebase.sample +169 -0
- data/scenarios/git_repositories/config.git/hooks/prepare-commit-msg.sample +36 -0
- data/scenarios/git_repositories/config.git/hooks/update.sample +128 -0
- data/scenarios/git_repositories/config.git/info/exclude +6 -0
- data/scenarios/git_repositories/config.git/objects/0d/b14dd6ddc54017c0a11960dcda82ed802cde69 +0 -0
- data/scenarios/git_repositories/config.git/objects/0e/e781f5ce80d64db32a74a7aae7b5248dafe112 +3 -0
- data/scenarios/git_repositories/config.git/objects/17/6bf54cf17d1d1c24556dc059c4144a5df230e8 +0 -0
- data/scenarios/git_repositories/config.git/objects/20/e9ff3feaa8ede30f707e5f1b4356e3c02bb7ec +0 -0
- data/scenarios/git_repositories/config.git/objects/45/117b1c775f0de415478dbf08ed9d667ab17d13 +0 -0
- data/scenarios/git_repositories/config.git/objects/51/3954c9aca090e6ce40359f0e9fde30ea78eb8c +0 -0
- data/scenarios/git_repositories/config.git/objects/66/947a7a11a6f5d3d561fe95de284ced3010819a +0 -0
- data/scenarios/git_repositories/config.git/objects/6b/bc79311bfac47d3ed724aa82a4814e0dda4c67 +0 -0
- data/scenarios/git_repositories/config.git/objects/71/eb5df52676e8e6efba471050b46978173af110 +1 -0
- data/scenarios/git_repositories/config.git/objects/84/17d2fe3e8fcc0825249c517b29b0f9ea8b8b31 +2 -0
- data/scenarios/git_repositories/config.git/objects/8b/384fcfcf7c0dee7c3c1d5636bee9e645d9cf38 +0 -0
- data/scenarios/git_repositories/config.git/objects/bb/59da633ba74296b0c2f9ff70784ac155ddb599 +0 -0
- data/scenarios/git_repositories/config.git/objects/cc/b86b26189afbf45d8eb9165812ab86dbdfca63 +0 -0
- data/scenarios/git_repositories/config.git/objects/d1/0bcd51fec41f854001e4d61f99d9e282a695d3 +0 -0
- data/scenarios/git_repositories/config.git/objects/d8/a8b0f5b1fd66844efb141d9544965ea0065f2d +0 -0
- data/scenarios/git_repositories/config.git/objects/e6/b02c66ad632e6b8535c4630cb8fe07732a72fc +0 -0
- data/scenarios/git_repositories/config.git/objects/e8/b8bfeeba735c0a1a873082554cb4d7256ac125 +0 -0
- data/scenarios/git_repositories/config.git/objects/f9/0181466a1a60b793ca9cc9abd584c18d4e3887 +0 -0
- data/scenarios/git_repositories/config.git/objects/f9/49d5d8a4f12c91471e34d4e277239c35ebd10d +0 -0
- data/scenarios/git_repositories/config.git/refs/heads/master +1 -0
- data/scenarios/git_repositories/project.git/HEAD +1 -0
- data/scenarios/git_repositories/project.git/config +5 -0
- data/scenarios/git_repositories/project.git/description +1 -0
- data/scenarios/git_repositories/project.git/git-daemon-export-ok +0 -0
- data/scenarios/git_repositories/project.git/hooks/applypatch-msg.sample +15 -0
- data/scenarios/git_repositories/project.git/hooks/commit-msg.sample +24 -0
- data/scenarios/git_repositories/project.git/hooks/post-commit.sample +8 -0
- data/scenarios/git_repositories/project.git/hooks/post-receive.sample +15 -0
- data/scenarios/git_repositories/project.git/hooks/post-update.sample +8 -0
- data/scenarios/git_repositories/project.git/hooks/pre-applypatch.sample +14 -0
- data/scenarios/git_repositories/project.git/hooks/pre-commit.sample +46 -0
- data/scenarios/git_repositories/project.git/hooks/pre-rebase.sample +169 -0
- data/scenarios/git_repositories/project.git/hooks/prepare-commit-msg.sample +36 -0
- data/scenarios/git_repositories/project.git/hooks/update.sample +128 -0
- data/scenarios/git_repositories/project.git/info/exclude +6 -0
- data/scenarios/git_repositories/project.git/objects/04/26e152e66c8cd42974279bdcae09be9839c172 +0 -0
- data/scenarios/git_repositories/project.git/objects/04/f4de85eaf72ef1631dc6d7424045c0a749b757 +1 -0
- data/scenarios/git_repositories/project.git/objects/06/13fe277280cbcdb2856e1eefc70bdaff011b20 +0 -0
- data/scenarios/git_repositories/project.git/objects/06/7aca89b86265eee211387434c3e50f37ccf009 +0 -0
- data/scenarios/git_repositories/project.git/objects/09/445dacc4822722612d60833c9948219ecdd8f5 +0 -0
- data/scenarios/git_repositories/project.git/objects/11/c4ec64326de35462f4e79d0f4229bf8e26e0c5 +0 -0
- data/scenarios/git_repositories/project.git/objects/20/1c7641c2e42b0b904e5c1f793489d8b858e4da +2 -0
- data/scenarios/git_repositories/project.git/objects/23/979639da60d2d31e9744468df1c1221b101e64 +0 -0
- data/scenarios/git_repositories/project.git/objects/27/a3fff2c4c45ab5513a405f694c0a042cb5d417 +1 -0
- data/scenarios/git_repositories/project.git/objects/2c/0c33cfba8e1af15df88522c0db2b10a6a94138 +2 -0
- data/scenarios/git_repositories/project.git/objects/38/b574660305ecb5fec6b2daa7ee1e0dbf1b6003 +0 -0
- data/scenarios/git_repositories/project.git/objects/4a/57abb5e4e426cfc9101b3af22ac83ccbd8e2ad +0 -0
- data/scenarios/git_repositories/project.git/objects/4c/77ebdd985e57afe7988480720c5dc77ec525c9 +0 -0
- data/scenarios/git_repositories/project.git/objects/51/c94da6f1b8aa9d2346088d3d362475b60c7f32 +0 -0
- data/scenarios/git_repositories/project.git/objects/5b/a96acf9cc9b87babe37c032676f53bf1ba9ae7 +2 -0
- data/scenarios/git_repositories/project.git/objects/5d/f555601d60f1c2a84d2364af0ad640612c3ba5 +0 -0
- data/scenarios/git_repositories/project.git/objects/71/03b5ac94940d596c2160a5cfcd55ca4ccac41f +0 -0
- data/scenarios/git_repositories/project.git/objects/73/3fc331098b03523f414f3509b9ae6e637c6866 +0 -0
- data/scenarios/git_repositories/project.git/objects/80/26076649ceccbe96a6292f2432652f08483035 +0 -0
- data/scenarios/git_repositories/project.git/objects/86/d1ef0976be4567de562224e1b51fbf9820c53a +1 -0
- data/scenarios/git_repositories/project.git/objects/87/a9d8b09b3401d21b23d90253332d6b28b47db2 +0 -0
- data/scenarios/git_repositories/project.git/objects/8b/030ba688255c917d189ae3f87d7c5ccd226bc2 +0 -0
- data/scenarios/git_repositories/project.git/objects/95/c9d5ad9b1c90e4c805516783105fc2037dedeb +2 -0
- data/scenarios/git_repositories/project.git/objects/95/d82d043af35a80eabfd56c0d705abfa3488787 +2 -0
- data/scenarios/git_repositories/project.git/objects/96/0bf34bb0b46d0aeb0be87f688f4ef06a4b35e1 +0 -0
- data/scenarios/git_repositories/project.git/objects/a3/860106dc1d148c7831cd45ae38829b4ed47702 +2 -0
- data/scenarios/git_repositories/project.git/objects/a8/506d6439b71784a72ac72d284b2ad53088f573 +0 -0
- data/scenarios/git_repositories/project.git/objects/ad/22ea6c7563777936ecfbe50d8e2cf8120fd525 +0 -0
- data/scenarios/git_repositories/project.git/objects/ae/3900de54aff557c61c81146d00f9d38e55a265 +1 -0
- data/scenarios/git_repositories/project.git/objects/bf/5e3740d52b80abb0378b3f85f93a53b1294521 +1 -0
- data/scenarios/git_repositories/project.git/objects/bf/b59811cdbc069418dee14b171e6e7e979784b7 +0 -0
- data/scenarios/git_repositories/project.git/objects/cc/5ac0afb24e727d5de344cc26a425f4fb7fd17d +3 -0
- data/scenarios/git_repositories/project.git/objects/d1/091aa2dd76885108461110c639e6b33a297fce +0 -0
- data/scenarios/git_repositories/project.git/objects/d8/913f6650eb2b7bf2a633732d8452008ca23dcb +0 -0
- data/scenarios/git_repositories/project.git/objects/db/d1b9667f1b26b13331ac0c321dced8be1aeab0 +3 -0
- data/scenarios/git_repositories/project.git/objects/e4/3b9107e9b1908ce415025e64eb83a493d329b7 +0 -0
- data/scenarios/git_repositories/project.git/objects/ef/2a88894d5421920b9dfe67a9a4d8043830e62e +0 -0
- data/scenarios/git_repositories/project.git/objects/f4/0123a1ff20c65d8dc15a38a83222647908e6f7 +0 -0
- data/scenarios/git_repositories/project.git/objects/f5/0af315b75ca0b12c720dec6d916b76b968c319 +0 -0
- data/scenarios/git_repositories/project.git/objects/f6/0215709b7b23f3738e9cbaf634b1c86bbd376a +0 -0
- data/scenarios/git_repositories/project.git/refs/heads/bad_rakefile +1 -0
- data/scenarios/git_repositories/project.git/refs/heads/hook_with_changed +1 -0
- data/scenarios/git_repositories/project.git/refs/heads/master +1 -0
- data/scenarios/git_repositories/project.git/refs/heads/no_rake_hooks +1 -0
- data/scenarios/git_repositories/project.git/refs/heads/post_rake_tasks +1 -0
- data/scenarios/invalid/deploy.yml +1 -0
- data/scenarios/local/deploy.yml.erb +17 -0
- data/scenarios/remote/deploy.yml +119 -0
- data/scenarios/setup/vagrant/.gitignore +3 -0
- data/scenarios/setup/vagrant/Vagrantfile +10 -0
- data/scenarios/setup/vagrant/manifests/integration.pp +32 -0
- data/scenarios/setup/vagrant/pids/.gitignore +1 -0
- data/spec/.bacon +0 -0
- data/spec/init_spec.rb +9 -0
- data/spec/install_spec.rb +43 -0
- data/spec/integration/branch_switching_spec.rb +41 -0
- data/spec/integration/deployment_failures_spec.rb +106 -0
- data/spec/integration/helper_spec.rb +90 -0
- data/spec/integration/invalid_configuration_spec.rb +39 -0
- data/spec/integration/local_deployments_spec.rb +230 -0
- data/spec/integration/post_rake_tasks_spec.rb +226 -0
- data/spec/integration/post_scripts_spec.rb +246 -0
- data/spec/integration/remote_deployments_spec.rb +166 -0
- data/spec/integration/staleness_checks_spec.rb +72 -0
- data/spec/spec_helper.rb +117 -0
- data/spec/wd_command_spec.rb +986 -0
- data/spec/wd_role_command_spec.rb +49 -0
- data/spec/whiskey_disk/config/filter_spec.rb +77 -0
- data/spec/whiskey_disk/config/filters/add_environment_name_filter_spec.rb +20 -0
- data/spec/whiskey_disk/config/filters/add_project_name_filter_spec.rb +19 -0
- data/spec/whiskey_disk/config/filters/check_for_duplicate_domains_filter_spec.rb +29 -0
- data/spec/whiskey_disk/config/filters/convert_role_strings_to_list_filter_spec.rb +48 -0
- data/spec/whiskey_disk/config/filters/default_config_target_filter_spec.rb +19 -0
- data/spec/whiskey_disk/config/filters/default_domain_filter_spec.rb +18 -0
- data/spec/whiskey_disk/config/filters/drop_empty_domain_roles_filter_spec.rb +60 -0
- data/spec/whiskey_disk/config/filters/environment_scope_filter_spec.rb +32 -0
- data/spec/whiskey_disk/config/filters/hashify_domain_entries_filter_spec.rb +41 -0
- data/spec/whiskey_disk/config/filters/localize_domains_filter_spec.rb +30 -0
- data/spec/whiskey_disk/config/filters/normalize_ssh_options_filter_spec.rb +56 -0
- data/spec/whiskey_disk/config/filters/project_scope_filter_spec.rb +75 -0
- data/spec/whiskey_disk/config/filters/select_project_and_environment_filter_spec.rb +30 -0
- data/spec/whiskey_disk/config/filters/stringify_hash_keys_filter_spec.rb +40 -0
- data/spec/whiskey_disk/config_spec.rb +754 -0
- data/spec/whiskey_disk/helpers_spec.rb +443 -0
- data/spec/whiskey_disk/rake_spec.rb +261 -0
- data/spec/whiskey_disk_spec.rb +1224 -0
- data/tasks/deploy.rake +2 -0
- data/whisk_deploy.gemspec +215 -0
- metadata +242 -0
data/Rakefile
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'rake/testtask'
|
3
|
+
|
4
|
+
desc 'Default: run unit tests.'
|
5
|
+
task :default => :test
|
6
|
+
|
7
|
+
desc 'Test whiskey_disk'
|
8
|
+
task :test do
|
9
|
+
files = Dir['spec/**/*_spec.rb'].join(" ")
|
10
|
+
system("bacon #{files}")
|
11
|
+
end
|
12
|
+
|
13
|
+
namespace :integration do
|
14
|
+
def say(mesg)
|
15
|
+
STDERR.puts mesg
|
16
|
+
end
|
17
|
+
|
18
|
+
def vagrant_path
|
19
|
+
File.expand_path(File.join(File.dirname(__FILE__), 'scenarios', 'setup', 'vagrant'))
|
20
|
+
end
|
21
|
+
|
22
|
+
def root_path
|
23
|
+
File.expand_path(File.dirname(__FILE__))
|
24
|
+
end
|
25
|
+
|
26
|
+
def pidfile
|
27
|
+
File.join(vagrant_path, 'pids', 'git-daemon.pid')
|
28
|
+
end
|
29
|
+
|
30
|
+
def start_git_daemon
|
31
|
+
stop_git_daemon
|
32
|
+
say "Starting git daemon..."
|
33
|
+
run(root_path, "git daemon --base-path=#{root_path}/scenarios/git_repositories/ --reuseaddr --verbose --detach --pid-file=#{pidfile}")
|
34
|
+
end
|
35
|
+
|
36
|
+
def stop_git_daemon
|
37
|
+
return unless File.exists?(pidfile)
|
38
|
+
pid = File.read(pidfile).chomp
|
39
|
+
return if pid == ''
|
40
|
+
say "Stopping git daemon..."
|
41
|
+
run(root_path, "kill #{pid}")
|
42
|
+
end
|
43
|
+
|
44
|
+
def start_vm
|
45
|
+
say "Bringing up vagrant vm..."
|
46
|
+
run(vagrant_path, 'vagrant up')
|
47
|
+
copy_ssh_config
|
48
|
+
end
|
49
|
+
|
50
|
+
def stop_vm
|
51
|
+
say "Shutting down vagrant vm..."
|
52
|
+
run(vagrant_path, 'vagrant halt')
|
53
|
+
end
|
54
|
+
|
55
|
+
def copy_ssh_config
|
56
|
+
say "Capturing vagrant ssh_config data..."
|
57
|
+
run(vagrant_path, "vagrant ssh_config > #{vagrant_path}/ssh_config")
|
58
|
+
end
|
59
|
+
|
60
|
+
def run(path, cmd)
|
61
|
+
Dir.chdir(path)
|
62
|
+
say "running: #{cmd} [cwd: #{Dir.pwd}]"
|
63
|
+
system(cmd)
|
64
|
+
end
|
65
|
+
|
66
|
+
desc 'Start a vagrant VM and git-daemon server to support running integration specs'
|
67
|
+
task :up do
|
68
|
+
start_vm
|
69
|
+
start_git_daemon
|
70
|
+
end
|
71
|
+
|
72
|
+
desc 'Shut down integration vagrant VM and git-daemon server'
|
73
|
+
task :down do
|
74
|
+
stop_git_daemon
|
75
|
+
stop_vm
|
76
|
+
end
|
77
|
+
|
78
|
+
desc 'Completely remove the vagrant VM files used by the integration spec suite'
|
79
|
+
task :destroy do
|
80
|
+
stop_git_daemon
|
81
|
+
stop_vm
|
82
|
+
run(vagrant_path, 'vagrant destroy')
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
begin
|
87
|
+
require 'jeweler'
|
88
|
+
Jeweler::Tasks.new do |gemspec|
|
89
|
+
gemspec.name = "whisk_deploy"
|
90
|
+
gemspec.summary = "embarrassingly fast deployments."
|
91
|
+
gemspec.description = "Opinionated gem for doing fast git-based server deployments."
|
92
|
+
gemspec.email = "jesse@techno-geeks.org"
|
93
|
+
gemspec.homepage = "http://github.com/jesseadams/whiskey"
|
94
|
+
gemspec.authors = ["Rick Bradley", "Jesse R. Adams"]
|
95
|
+
gemspec.add_dependency('rake')
|
96
|
+
|
97
|
+
# I've decided that the integration spec shizzle shouldn't go into the gem
|
98
|
+
rejected = %w(scenarios spec/integration)
|
99
|
+
gemspec.files.reject {|f| rejected.include?(f) }
|
100
|
+
gemspec.test_files.reject {|f| rejected.include?(f) }
|
101
|
+
end
|
102
|
+
Jeweler::GemcutterTasks.new
|
103
|
+
rescue LoadError
|
104
|
+
# if you get here, you need Jeweler installed to do packaging and gem installation, yo.
|
105
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.6.25
|
data/WHY.txt
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
|
2
|
+
Why?
|
3
|
+
----
|
4
|
+
|
5
|
+
The idea here is inspired by github's cleaned up deployment scripts, and mislav's git-deploy project. Only, we gave up on capistrano a long time ago, and after a few years of doing deployments on a few dozen projects, we realized that we really have very little variation on how we do things. That is, we can afford to have a very opinionated tool to do our deployments.
|
6
|
+
|
7
|
+
Here are the features/constraints we're envisioning:
|
8
|
+
|
9
|
+
- We need some sort of very basic "run this on a remote server" functionality. We've been using vlad for years now and this would suffice: it does what we're looking for and is much much smaller than capistrano. If we don't load the included recipes it's basically a fancy ruby ssh wrapper.
|
10
|
+
|
11
|
+
- Setup should mostly just do a very fast remote git checkout in the right place. Deployment should very quickly update that checkout.
|
12
|
+
|
13
|
+
- We have been considering a move towards tracking configuration data across our projects as a separate concern. So, if we can have a private repo that stores per-project and per-environment (here I mean staging vs. production, etc.) configuration files, and have our deployments overlay those files quickly, that would be ideal. I'm talking about hoptoad configs, database.yml files, AWS cert files, GeoKit API keys, etc., etc., etc.
|
14
|
+
|
15
|
+
- We should be able to use the same "setup == clone" + "deploy == reset" technique to manage the per-project/per-environment config files.
|
16
|
+
|
17
|
+
- Using rsync on the remote to those overlay config files on the deployed project would be a fast way to get them in place.
|
18
|
+
|
19
|
+
- Get rid of a bunch of annoying symlinks and symlink-hoops-to-jump-through.
|
20
|
+
|
21
|
+
- Get rid of a bunch of space (yeah yeah disk is cheap, but copying isn't) on the disk devoted to umpteen "releases".
|
22
|
+
|
23
|
+
- Obviously reduce deployment time by doing less, ssh-ing less, and taking less time to do whatever.
|
24
|
+
|
25
|
+
- should be rake based, and should provide a bare minimum of tasks -- like deploy:setup, deploy:now, and maybe a deploy:refresh_config_files.
|
26
|
+
|
27
|
+
- While a very basic task or few would run after setup or after deployment (e.g., rake db:migrate if migrations were changed, or touch tmp/restart.txt if the web server needs a restart; see git-deploy for more examples), we should be able to declare optional rake tasks (e.g., "deploy:staging:post_deploy") and have them run on this project if they are declared.
|
28
|
+
|
29
|
+
- Should work with projects that aren't remotely ruby.
|
30
|
+
|
31
|
+
- Should be loadable as a gem, meaning that it doesn't need to live in your project's space. (see also non-ruby projects)
|
32
|
+
|
33
|
+
- Should be able to use a non-ruby config, preferably yaml, for information for all environments. That could be stored in <project>/config/deploy.yml and saved with the project. Even if this is just shoved into vlad 'set' commands, it's still an improvement: we don't need ruby in the config file because we're opinionated.
|
34
|
+
|
35
|
+
- should be able to override settings for an environment locally by declaring a <project>/config/deploy-<environment>.yml. Ideal for testing out deployments to different servers (or deploying locally). This also makes it possible to .gitignore your local settings, so everyone can have their config repos in different places.
|
36
|
+
|
37
|
+
- should make it easier to do local development (e.g., on a laptop) by being able to overlay config files using the same rake tasks as used for remote deployments, just not running the functionality remotely.
|
38
|
+
|
39
|
+
- dropping in a project Rakefile can add post-deploy / post-setup hooks transparently.
|
40
|
+
|
41
|
+
- actually have meaningful error messages, unlike anything that ever seems to happen with cap or vlad. :-/
|
42
|
+
|
43
|
+
- build this spec-first (whenever possible) so that there's a useful test suite.
|
44
|
+
|
45
|
+
- M$ windows hasn't been a priority for me for over a decade, not starting now.
|
data/bin/wd
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'optparse'
|
4
|
+
require 'whiskey_disk/rake'
|
5
|
+
|
6
|
+
$0 = "#{$0} setup|deploy" # jesus, this is a hack.
|
7
|
+
|
8
|
+
options = {}
|
9
|
+
op = OptionParser.new do |opts|
|
10
|
+
opts.on('-t=TARGET', '--to=TARGET', "deployment target") do |target|
|
11
|
+
options[:target] = target
|
12
|
+
end
|
13
|
+
|
14
|
+
opts.on('-p=TARGET', '--path=TARGET', "configuration path") do |path|
|
15
|
+
options[:path] = path
|
16
|
+
end
|
17
|
+
|
18
|
+
opts.on('-o=DOMAIN', '--only=DOMAIN', "limit deployment to this domain") do |domain|
|
19
|
+
options[:only] = domain
|
20
|
+
end
|
21
|
+
|
22
|
+
opts.on('-c', '--check', "do a staleness check before deploying") do |path|
|
23
|
+
options[:check] = 'true'
|
24
|
+
end
|
25
|
+
|
26
|
+
opts.on('-d', '--debug', "turn on debug mode (ssh -v and rake --trace)") do
|
27
|
+
options[:debug] = 'true'
|
28
|
+
end
|
29
|
+
|
30
|
+
opts.on('--version', 'show current version') do
|
31
|
+
puts File.read(File.expand_path(File.join(File.dirname(__FILE__), '..', 'VERSION')))
|
32
|
+
exit 0
|
33
|
+
end
|
34
|
+
|
35
|
+
opts.on_tail('-h', '--help', 'show this message') do
|
36
|
+
abort opts.to_s
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
begin
|
41
|
+
rest = op.parse(ARGV)
|
42
|
+
rescue
|
43
|
+
abort op.to_s
|
44
|
+
end
|
45
|
+
|
46
|
+
abort op.to_s unless options[:target]
|
47
|
+
abort op.to_s unless rest and rest.size == 1
|
48
|
+
command = rest.first
|
49
|
+
abort op.to_s unless ['deploy', 'setup'].include?(command)
|
50
|
+
|
51
|
+
ENV['to'] = options[:target]
|
52
|
+
ENV['path'] = options[:path]
|
53
|
+
ENV['only'] = options[:only]
|
54
|
+
ENV['check'] = options[:check]
|
55
|
+
ENV['debug'] = options[:debug]
|
56
|
+
|
57
|
+
if command == 'deploy'
|
58
|
+
Rake::Task['deploy:now'].invoke
|
59
|
+
else
|
60
|
+
Rake::Task['deploy:setup'].invoke
|
61
|
+
end
|
data/bin/wd_role
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'whiskey_disk/helpers'
|
3
|
+
|
4
|
+
# simple command-line script to detect whether this deployment target
|
5
|
+
# is in a specified whiskey_disk role. sets exit status appropriately
|
6
|
+
#
|
7
|
+
# useful for conditionalizing shell scripting based on roles.
|
8
|
+
|
9
|
+
role = ARGV.shift
|
10
|
+
exit(1) unless role?(role)
|
11
|
+
|
12
|
+
__END__
|
13
|
+
|
14
|
+
( this example session presumes you `gem install rockhands` first... )
|
15
|
+
|
16
|
+
$ export WD_ROLES='app:db'
|
17
|
+
$ wd_role web && rock || shocker
|
18
|
+
.-.
|
19
|
+
.-.U|
|
20
|
+
|U| | .-.
|
21
|
+
| | |_|U|
|
22
|
+
| | | | |
|
23
|
+
/| ` |
|
24
|
+
| | |
|
25
|
+
| |
|
26
|
+
\ /
|
27
|
+
| |
|
28
|
+
| |
|
29
|
+
|
30
|
+
$ wd_role app && rock || shocker
|
31
|
+
.-.
|
32
|
+
|U|
|
33
|
+
| | .-.
|
34
|
+
| |-._|U|
|
35
|
+
| | | | |
|
36
|
+
/| ` |
|
37
|
+
| | |
|
38
|
+
| |
|
39
|
+
/
|
40
|
+
| |
|
41
|
+
| |
|
42
|
+
|
@@ -0,0 +1,13 @@
|
|
1
|
+
production:
|
2
|
+
domain: "ogc@hoenir.websages.com"
|
3
|
+
deploy_to: "/tmp/test-deployment"
|
4
|
+
deploy_config_to: "/tmp/test-config"
|
5
|
+
repository: "git@git.ogtastic.com:whiskey_disk.git"
|
6
|
+
config_repository: "git@git.ogtastic.com:ogc-config.git"
|
7
|
+
staging:
|
8
|
+
domain: "ogc@hoenir.websages.com"
|
9
|
+
deploy_to: "/tmp/test-deployment"
|
10
|
+
deploy_config_to: "/tmp/test-config"
|
11
|
+
repository: "git@git.ogtastic.com:whiskey_disk.git"
|
12
|
+
config_repository: "git@git.ogtastic.com:ogc-config.git"
|
13
|
+
config_target: "local"
|
@@ -0,0 +1,26 @@
|
|
1
|
+
multi:
|
2
|
+
domain:
|
3
|
+
- "ogc@hoenir.websages.com"
|
4
|
+
- "ogc@nerthus.websages.com"
|
5
|
+
deploy_to: "/tmp/test-deployment/"
|
6
|
+
repository: "git@git.ogtastic.com:whiskey_disk.git"
|
7
|
+
branch: "develop"
|
8
|
+
roles:
|
9
|
+
domain:
|
10
|
+
- name: "ogc@hoenir.websages.com"
|
11
|
+
roles:
|
12
|
+
- web
|
13
|
+
- app
|
14
|
+
- name: "ogc@nerthus.websages.com"
|
15
|
+
roles: db
|
16
|
+
deploy_to: "/tmp/test-deployment/"
|
17
|
+
repository: "git@git.ogtastic.com:whiskey_disk.git"
|
18
|
+
branch: "feature/support-domain-roles"
|
19
|
+
post_deploy_script: "/tmp/role-checker.sh"
|
20
|
+
badmulti:
|
21
|
+
domain:
|
22
|
+
- "ogc@hoenir.websages.com"
|
23
|
+
- "ogc@bogus.example.com"
|
24
|
+
deploy_to: "/tmp/test-deployment/"
|
25
|
+
repository: "git@git.ogtastic.com:whiskey_disk.git"
|
26
|
+
branch: "develop"
|
@@ -0,0 +1,8 @@
|
|
1
|
+
staging:
|
2
|
+
domain: "user@www.example.com"
|
3
|
+
deploy_to: "/var/www/suparsite.com/"
|
4
|
+
repository: "git://github.com/clarkkent/suparsite.git"
|
5
|
+
config_repository: "git@github.com:clarkkent/suparconfig.git"
|
6
|
+
deploy_config_to: "/var/cache/git/suparconfig"
|
7
|
+
rake_env:
|
8
|
+
RAILS_ENV: 'development'
|
data/examples/deploy.yml
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
staging:
|
2
|
+
domain: "user@www.example.com"
|
3
|
+
deploy_to: "/var/www/suparsite.com/"
|
4
|
+
repository: "git://github.com/clarkkent/suparsite.git"
|
5
|
+
config_repository: "git@github.com:clarkkent/suparconfig.git"
|
6
|
+
deploy_config_to: "/var/cache/git/suparconfig"
|
7
|
+
branch: "production"
|
8
|
+
rake_env:
|
9
|
+
RAILS_ENV: "production"
|
10
|
+
local:
|
11
|
+
repository: "git://github.com/clarkkent/suparsite.git"
|
12
|
+
config_repository: "git@github.com:clarkkent/suparconfig.git"
|
13
|
+
deploy_to: "/Users/clark/git/suparsite"
|
14
|
+
deploy_config_to: "/Users/clark/git/suparconfig"
|
15
|
+
rake_env:
|
16
|
+
RAILS_ENV: "production"
|
data/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'lib', 'whiskey_disk'))
|
data/install.rb
ADDED
data/lib/whiskey_disk.rb
ADDED
@@ -0,0 +1,327 @@
|
|
1
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'whiskey_disk', 'config'))
|
2
|
+
|
3
|
+
class WhiskeyDisk
|
4
|
+
attr_writer :configuration, :config
|
5
|
+
attr_reader :results
|
6
|
+
|
7
|
+
def initialize(options = {})
|
8
|
+
@staleness_checks = true if options[:staleness_checks]
|
9
|
+
end
|
10
|
+
|
11
|
+
def buffer
|
12
|
+
@buffer ||= []
|
13
|
+
end
|
14
|
+
|
15
|
+
def config
|
16
|
+
@config ||= WhiskeyDisk::Config.new
|
17
|
+
end
|
18
|
+
|
19
|
+
def configuration
|
20
|
+
@configuration ||= config.fetch
|
21
|
+
end
|
22
|
+
|
23
|
+
def debugging?
|
24
|
+
config.debug?
|
25
|
+
end
|
26
|
+
|
27
|
+
def setting(key)
|
28
|
+
configuration[key.to_s]
|
29
|
+
end
|
30
|
+
|
31
|
+
def check_staleness?
|
32
|
+
config.check_staleness?
|
33
|
+
end
|
34
|
+
|
35
|
+
def staleness_checks_enabled?
|
36
|
+
!!@staleness_checks
|
37
|
+
end
|
38
|
+
|
39
|
+
def enqueue(command)
|
40
|
+
buffer << command
|
41
|
+
end
|
42
|
+
|
43
|
+
def remote?(domain)
|
44
|
+
return false unless domain
|
45
|
+
return false if domain == 'local'
|
46
|
+
limit = config.domain_limit
|
47
|
+
return false if limit and domain_limit_match?(domain, limit)
|
48
|
+
|
49
|
+
true
|
50
|
+
end
|
51
|
+
|
52
|
+
def has_config_repo?
|
53
|
+
! (setting(:config_repository).nil? or setting(:config_repository) == '')
|
54
|
+
end
|
55
|
+
|
56
|
+
def project_name_specified?
|
57
|
+
setting(:project) != 'unnamed_project'
|
58
|
+
end
|
59
|
+
|
60
|
+
def branch
|
61
|
+
(setting(:branch) and setting(:branch) != '') ? setting(:branch) : 'master'
|
62
|
+
end
|
63
|
+
|
64
|
+
def config_branch
|
65
|
+
(setting(:config_branch) and setting(:config_branch) != '') ? setting(:config_branch) : 'master'
|
66
|
+
end
|
67
|
+
|
68
|
+
def env_vars
|
69
|
+
return '' unless setting(:rake_env)
|
70
|
+
setting(:rake_env).keys.inject('') do |buffer,k|
|
71
|
+
buffer += "#{k}='#{setting(:rake_env)[k]}' "
|
72
|
+
buffer
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def parent_path(path)
|
77
|
+
File.split(path).first
|
78
|
+
end
|
79
|
+
|
80
|
+
def tail_path(path)
|
81
|
+
File.split(path).last
|
82
|
+
end
|
83
|
+
|
84
|
+
def needs(*keys)
|
85
|
+
keys.each do |key|
|
86
|
+
raise "No value for '#{key}' declared in configuration files [#{config.configuration_file}]" unless setting(key)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def apply_staleness_check(commands)
|
91
|
+
needs(:deploy_to, :repository)
|
92
|
+
|
93
|
+
check = "cd #{setting(:deploy_to)}; " +
|
94
|
+
"ml=\`git log -1 --pretty=format:%H\`; " +
|
95
|
+
"mr=\`git ls-remote #{setting(:repository)} refs/heads/#{branch}\`; "
|
96
|
+
|
97
|
+
if setting(:deploy_config_to)
|
98
|
+
check += "cd #{setting(:deploy_config_to)}; " +
|
99
|
+
"cl=\`git log -1 --pretty=format:%H\`; " +
|
100
|
+
"cr=\`git ls-remote #{setting(:config_repository)} refs/heads/#{config_branch}\`; "
|
101
|
+
end
|
102
|
+
|
103
|
+
check += "if [[ $ml != ${mr%%\t*} ]] " +
|
104
|
+
(setting(:deploy_config_to) ? "|| [[ $cl != ${cr%%\t*} ]]" : '') +
|
105
|
+
"; then #{commands}; else echo \"No changes to deploy.\"; fi"
|
106
|
+
end
|
107
|
+
|
108
|
+
def join_commands
|
109
|
+
buffer.collect {|c| "{ #{c} ; }"}.join(' && ')
|
110
|
+
end
|
111
|
+
|
112
|
+
def bundle
|
113
|
+
return '' if buffer.empty?
|
114
|
+
(staleness_checks_enabled? and check_staleness?) ? apply_staleness_check(join_commands) : join_commands
|
115
|
+
end
|
116
|
+
|
117
|
+
def domain_limit_match?(domain, limit)
|
118
|
+
domain.sub(%r{^.*@}, '') == limit
|
119
|
+
end
|
120
|
+
|
121
|
+
def domain_of_interest?(domain)
|
122
|
+
return true unless limit = config.domain_limit
|
123
|
+
domain_limit_match?(domain, limit)
|
124
|
+
end
|
125
|
+
|
126
|
+
def encode_roles(roles)
|
127
|
+
return '' unless roles and !roles.empty?
|
128
|
+
"export WD_ROLES='#{roles.join(':')}'; "
|
129
|
+
end
|
130
|
+
|
131
|
+
def build_command(domain, cmd)
|
132
|
+
"#{'set -x; ' if debugging?}" + encode_roles(domain['roles']) + cmd
|
133
|
+
end
|
134
|
+
|
135
|
+
def rake_command
|
136
|
+
(setting(:rake_command) and setting(:rake_command) != '') ? setting(:rake_command) : 'rake'
|
137
|
+
end
|
138
|
+
|
139
|
+
def run(domain, cmd)
|
140
|
+
ssh(domain, cmd)
|
141
|
+
end
|
142
|
+
|
143
|
+
def ssh(domain, cmd)
|
144
|
+
args = []
|
145
|
+
args << domain['name']
|
146
|
+
args << '-v' if debugging?
|
147
|
+
args += domain['ssh_options'] if domain['ssh_options']
|
148
|
+
args << build_command(domain, cmd)
|
149
|
+
|
150
|
+
puts "Running: ssh #{args.join(' ')}" if debugging?
|
151
|
+
system('ssh', *args)
|
152
|
+
end
|
153
|
+
|
154
|
+
def shell(domain, cmd)
|
155
|
+
puts "Running command locally: [#{cmd}]" if debugging?
|
156
|
+
system('bash', '-c', build_command(domain, cmd))
|
157
|
+
end
|
158
|
+
|
159
|
+
def flush
|
160
|
+
needs(:domain)
|
161
|
+
setting(:domain).each do |domain|
|
162
|
+
next unless domain_of_interest?(domain['name'])
|
163
|
+
puts "Deploying #{domain['name']}..."
|
164
|
+
status = remote?(domain['name']) ? run(domain, bundle) : shell(domain, bundle)
|
165
|
+
record_result(domain['name'], status)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def record_result(domain, status)
|
170
|
+
@results ||= []
|
171
|
+
@results << { 'domain' => domain, 'status' => status }
|
172
|
+
end
|
173
|
+
|
174
|
+
def summarize_results(results)
|
175
|
+
successes = failures = 0
|
176
|
+
results.each do |result|
|
177
|
+
puts "#{result['domain']} => #{result['status'] ? 'succeeded' : 'failed'}."
|
178
|
+
if result['status']
|
179
|
+
successes += 1
|
180
|
+
else
|
181
|
+
failures += 1
|
182
|
+
end
|
183
|
+
end
|
184
|
+
[successes + failures, successes, failures]
|
185
|
+
end
|
186
|
+
|
187
|
+
def summarize
|
188
|
+
puts "\nResults:"
|
189
|
+
if results and not results.empty?
|
190
|
+
total, successes, failures = summarize_results(results)
|
191
|
+
puts "Total: #{total} deployment#{total == 1 ? '' : 's'}, " +
|
192
|
+
"#{successes} success#{successes == 1 ? '' : 'es'}, " +
|
193
|
+
"#{failures} failure#{failures == 1 ? '' : 's'}."
|
194
|
+
else
|
195
|
+
puts "No deployments to report."
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def success?
|
200
|
+
return true if !results or results.empty?
|
201
|
+
results.all? {|result| result['status'] }
|
202
|
+
end
|
203
|
+
|
204
|
+
def if_file_present(path, cmd)
|
205
|
+
"if [ -e #{path} ]; then #{cmd}; fi"
|
206
|
+
end
|
207
|
+
|
208
|
+
def if_task_defined(task, cmd)
|
209
|
+
%Q(rakep=`#{env_vars} #{rake_command} -P` && if [[ `echo "${rakep}" | grep #{task}` != "" ]]; then #{cmd}; fi )
|
210
|
+
end
|
211
|
+
|
212
|
+
def safe_branch_checkout(path, my_branch)
|
213
|
+
%Q(cd #{path} && git checkout -b #{my_branch} origin/#{my_branch} || git checkout #{my_branch} origin/#{my_branch} || git checkout #{my_branch})
|
214
|
+
end
|
215
|
+
|
216
|
+
def clone_repository(repo, path, my_branch)
|
217
|
+
enqueue "cd #{parent_path(path)}"
|
218
|
+
enqueue("if [ -e #{path} ]; then echo 'Repository already cloned to [#{path}]. Skipping.'; " +
|
219
|
+
"else git clone #{repo} #{tail_path(path)} && #{safe_branch_checkout(path, my_branch)}; fi")
|
220
|
+
end
|
221
|
+
|
222
|
+
def refresh_checkout(path, repo_branch)
|
223
|
+
enqueue "cd #{path}"
|
224
|
+
enqueue "git fetch origin +refs/heads/#{repo_branch}:refs/remotes/origin/#{repo_branch} #{'&>/dev/null' unless debugging?}"
|
225
|
+
enqueue "git checkout --force #{repo_branch} #{'&>/dev/null' unless debugging?}"
|
226
|
+
enqueue "git reset --hard origin/#{repo_branch} #{'&>/dev/null' unless debugging?}"
|
227
|
+
end
|
228
|
+
|
229
|
+
def run_rake_task(path, task_name)
|
230
|
+
enqueue "echo Running rake #{task_name}..."
|
231
|
+
enqueue "cd #{path}"
|
232
|
+
enqueue(if_file_present("#{setting(:deploy_to)}/Rakefile",
|
233
|
+
if_task_defined(task_name, "#{env_vars} #{rake_command} #{'--trace' if debugging?} #{task_name} to=#{setting(:environment)}")))
|
234
|
+
end
|
235
|
+
|
236
|
+
def build_path(path)
|
237
|
+
return path if path =~ %r{^/}
|
238
|
+
File.join(setting(:deploy_to), path)
|
239
|
+
end
|
240
|
+
|
241
|
+
def run_script(script)
|
242
|
+
return unless script
|
243
|
+
enqueue(%Q<cd #{setting(:deploy_to)}; echo "Running post script..."; #{env_vars} bash #{'-x' if debugging?} #{build_path(script)}>)
|
244
|
+
end
|
245
|
+
|
246
|
+
def ensure_main_parent_path_is_present
|
247
|
+
needs(:deploy_to)
|
248
|
+
enqueue "mkdir -p #{parent_path(setting(:deploy_to))}"
|
249
|
+
end
|
250
|
+
|
251
|
+
def ensure_config_parent_path_is_present
|
252
|
+
needs(:deploy_config_to)
|
253
|
+
enqueue "mkdir -p #{parent_path(setting(:deploy_config_to))}"
|
254
|
+
end
|
255
|
+
|
256
|
+
def checkout_main_repository
|
257
|
+
needs(:deploy_to, :repository)
|
258
|
+
clone_repository(setting(:repository), setting(:deploy_to), branch)
|
259
|
+
end
|
260
|
+
|
261
|
+
def checkout_configuration_repository
|
262
|
+
needs(:deploy_config_to, :config_repository)
|
263
|
+
clone_repository(setting(:config_repository), setting(:deploy_config_to), config_branch)
|
264
|
+
end
|
265
|
+
|
266
|
+
def snapshot_git_revision
|
267
|
+
needs(:deploy_to)
|
268
|
+
enqueue "cd #{setting(:deploy_to)}"
|
269
|
+
enqueue %Q{ml=\`git log -1 --pretty=format:%H\`}
|
270
|
+
end
|
271
|
+
|
272
|
+
def initialize_git_changes
|
273
|
+
needs(:deploy_to)
|
274
|
+
enqueue "rm -f #{setting(:deploy_to)}/.whiskey_disk_git_changes"
|
275
|
+
snapshot_git_revision
|
276
|
+
end
|
277
|
+
|
278
|
+
def initialize_rsync_changes
|
279
|
+
needs(:deploy_to)
|
280
|
+
enqueue "rm -f #{setting(:deploy_to)}/.whiskey_disk_rsync_changes"
|
281
|
+
end
|
282
|
+
|
283
|
+
def initialize_all_changes
|
284
|
+
needs(:deploy_to)
|
285
|
+
initialize_git_changes
|
286
|
+
initialize_rsync_changes
|
287
|
+
end
|
288
|
+
|
289
|
+
def capture_git_changes
|
290
|
+
needs(:deploy_to)
|
291
|
+
enqueue "git diff --name-only ${ml}..HEAD > #{setting(:deploy_to)}/.whiskey_disk_git_changes"
|
292
|
+
end
|
293
|
+
|
294
|
+
def update_main_repository_checkout
|
295
|
+
needs(:deploy_to)
|
296
|
+
initialize_git_changes
|
297
|
+
refresh_checkout(setting(:deploy_to), branch)
|
298
|
+
capture_git_changes
|
299
|
+
end
|
300
|
+
|
301
|
+
def update_configuration_repository_checkout
|
302
|
+
needs(:deploy_config_to)
|
303
|
+
initialize_rsync_changes
|
304
|
+
refresh_checkout(setting(:deploy_config_to), config_branch)
|
305
|
+
end
|
306
|
+
|
307
|
+
def refresh_configuration
|
308
|
+
needs(:deploy_to, :deploy_config_to)
|
309
|
+
raise "Must specify project name when using a configuration repository." unless project_name_specified?
|
310
|
+
enqueue "echo Rsyncing configuration..."
|
311
|
+
enqueue("rsync -a#{'v --progress' if debugging?} " + '--log-format="%t [%p] %i %n" ' +
|
312
|
+
"#{setting(:deploy_config_to)}/#{setting(:project)}/#{setting(:config_target)}/ #{setting(:deploy_to)}/ " +
|
313
|
+
"> #{setting(:deploy_to)}/.whiskey_disk_rsync_changes")
|
314
|
+
end
|
315
|
+
|
316
|
+
def run_post_setup_hooks
|
317
|
+
needs(:deploy_to)
|
318
|
+
run_script(setting(:post_setup_script))
|
319
|
+
run_rake_task(setting(:deploy_to), "deploy:post_setup")
|
320
|
+
end
|
321
|
+
|
322
|
+
def run_post_deploy_hooks
|
323
|
+
needs(:deploy_to)
|
324
|
+
run_script(setting(:post_deploy_script))
|
325
|
+
run_rake_task(setting(:deploy_to), "deploy:post_deploy")
|
326
|
+
end
|
327
|
+
end
|