wordpress-deploy 1.0.0.alpha1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,152 @@
1
+ # encoding: utf-8
2
+
3
+ module WordpressDeploy
4
+ module Logger
5
+ class << self
6
+
7
+ attr_accessor :quiet
8
+
9
+ ##
10
+ # Outputs a messages to the console and writes it to the backup.log
11
+ def message(string)
12
+ to_console loggify(string, :message, :green)
13
+ to_file loggify(string, :message)
14
+ end
15
+
16
+ ##
17
+ # Outputs an error to the console and writes it to the backup.log
18
+ # Called when an Exception has caused the backup process to abort.
19
+ def error(string)
20
+ to_console loggify(string, :error, :red), true
21
+ to_file loggify(string, :error)
22
+ end
23
+
24
+ ##
25
+ # Outputs a notice to the console and writes it to the backup.log
26
+ # Sets #has_warnings? true so :on_warning notifications will be sent
27
+ def warn(string)
28
+ @has_warnings = true
29
+ to_console loggify(string, :warning, :yellow), true
30
+ to_file loggify(string, :warning)
31
+ end
32
+
33
+ # Outputs the data as if it were a regular 'puts' command,
34
+ # but also logs it to the backup.log
35
+ def normal(string)
36
+ to_console loggify(string)
37
+ to_file loggify(string)
38
+ end
39
+
40
+ ##
41
+ # Silently logs data to the log file
42
+ def silent(string)
43
+ to_file loggify(string, :silent)
44
+ end
45
+
46
+ ##
47
+ # Returns an Array of all messages written to the log file for this session
48
+ def messages
49
+ @messages ||= []
50
+ end
51
+
52
+ ##
53
+ # Returns true if any warnings have been issued
54
+ def has_warnings?
55
+ @has_warnings ||= false
56
+ end
57
+
58
+ def clear!
59
+ messages.clear
60
+ @has_warnings = false
61
+ end
62
+
63
+ def truncate!(max_bytes = 500_000)
64
+ log_file = File.join(Config.log_path, 'backup.log')
65
+ return unless File.exist?(log_file)
66
+
67
+ if File.stat(log_file).size > max_bytes
68
+ FileUtils.mv(log_file, log_file + '~')
69
+ File.open(log_file + '~', 'r') do |io_in|
70
+ File.open(log_file, 'w') do |io_out|
71
+ io_in.seek(-max_bytes, IO::SEEK_END) && io_in.gets
72
+ while line = io_in.gets
73
+ io_out.puts line
74
+ end
75
+ end
76
+ end
77
+ FileUtils.rm_f(log_file + '~')
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ ##
84
+ # Returns the time in [YYYY/MM/DD HH:MM:SS] format
85
+ def time
86
+ Time.now.strftime("%Y/%m/%d %H:%M:%S")
87
+ end
88
+
89
+ ##
90
+ # Receives a String, or an Object that responds to #to_s (e.g. an
91
+ # Exception), from one of the messaging methods and converts it into an
92
+ # Array of Strings, split on newline separators. Each line is then
93
+ # formatted into a log format based on the given options, and the Array
94
+ # returned to be passed to to_console() and/or to_file().
95
+ def loggify(string, type = false, color = false)
96
+ lines = string.to_s.split("\n")
97
+ if type
98
+ type = send(color, type) if color
99
+ time_now = time
100
+ lines.map {|line| "[#{time_now}][#{type}] #{line}" }
101
+ else
102
+ lines
103
+ end
104
+ end
105
+
106
+ ##
107
+ # Receives an Array of Strings to be written to the console.
108
+ def to_console(lines, stderr = false)
109
+ return if quiet
110
+ lines.each {|line| stderr ? Kernel.warn(line) : puts(line) }
111
+ end
112
+
113
+ ##
114
+ # Receives an Array of Strings to be written to the log file.
115
+ def to_file(lines)
116
+ File.open(File.join(Config.log_path, 'backup.log'), 'a') do |file|
117
+ lines.each {|line| file.puts line }
118
+ end
119
+ messages.push(*lines)
120
+ end
121
+
122
+ ##
123
+ # Invokes the #colorize method with the provided string
124
+ # and the color code "32" (for green)
125
+ def green(string)
126
+ colorize(string, 32)
127
+ end
128
+
129
+ ##
130
+ # Invokes the #colorize method with the provided string
131
+ # and the color code "33" (for yellow)
132
+ def yellow(string)
133
+ colorize(string, 33)
134
+ end
135
+
136
+ ##
137
+ # Invokes the #colorize method the with provided string
138
+ # and the color code "31" (for red)
139
+ def red(string)
140
+ colorize(string, 31)
141
+ end
142
+
143
+ ##
144
+ # Wraps the provided string in colorizing tags to provide
145
+ # easier to view output to the client
146
+ def colorize(string, code)
147
+ "\e[#{code}m#{string}\e[0m"
148
+ end
149
+
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,110 @@
1
+ # encoding: utf-8
2
+
3
+ module WordpressDeploy
4
+ class Pipeline
5
+ include WordpressDeploy::CLI::Helpers
6
+
7
+ attr_reader :stderr, :errors
8
+
9
+ def initialize
10
+ @commands = []
11
+ @errors = []
12
+ @stderr = ''
13
+ end
14
+
15
+ ##
16
+ # Adds a command to be executed in the pipeline.
17
+ # Each command will be run in the order in which it was added,
18
+ # with it's output being piped to the next command.
19
+ def <<(command)
20
+ @commands << command
21
+ end
22
+
23
+ ##
24
+ # Runs the command line from `#pipeline` and collects STDOUT/STDERR.
25
+ # STDOUT is then parsed to determine the exit status of each command.
26
+ # For each command with a non-zero exit status, a SystemCallError is
27
+ # created and added to @errors. All STDERR output is set in @stderr.
28
+ #
29
+ # Note that there is no accumulated STDOUT from the commands themselves.
30
+ # Also, the last command should not attempt to write to STDOUT.
31
+ # Any output on STDOUT from the final command will be sent to STDERR.
32
+ # This in itself will not cause #run to fail, but will log warnings
33
+ # when all commands exit with non-zero status.
34
+ #
35
+ # Use `#success?` to determine if all commands in the pipeline succeeded.
36
+ # If `#success?` returns `false`, use `#error_messages` to get an error report.
37
+ def run
38
+ Open4.popen4(pipeline) do |pid, stdin, stdout, stderr|
39
+ pipestatus = stdout.read.gsub("\n", '').split(':').sort
40
+ pipestatus.each do |status|
41
+ index, exitstatus = status.split('|').map(&:to_i)
42
+ if exitstatus > 0
43
+ command = command_name(@commands[index])
44
+ @errors << SystemCallError.new(
45
+ "'#{ command }' returned exit code: #{ exitstatus }", exitstatus
46
+ )
47
+ end
48
+ end
49
+ @stderr = stderr.read.strip
50
+ end
51
+ Logger.warn(stderr_messages) if success? && stderr_messages
52
+ rescue Exception => e
53
+ raise Errors::Pipeline::ExecutionError.wrap(e)
54
+ end
55
+
56
+ def success?
57
+ @errors.empty?
58
+ end
59
+
60
+ ##
61
+ # Returns a multi-line String, reporting all STDERR messages received
62
+ # from the commands in the pipeline (if any), along with the SystemCallError
63
+ # (Errno) message for each command which had a non-zero exit status.
64
+ #
65
+ # Each error is wrapped by WordpressDeploy::Errors to provide formatting.
66
+ def error_messages
67
+ @error_messages ||= (stderr_messages || '') +
68
+ "The following system errors were returned:\n" +
69
+ @errors.map {|err| Errors::Error.wrap(err).message }.join("\n")
70
+ end
71
+
72
+ private
73
+
74
+ ##
75
+ # Each command is added as part of the pipeline, grouped with an `echo`
76
+ # command to pass along the command's index in @commands and it's exit status.
77
+ # The command's STDERR is redirected to FD#4, and the `echo` command to
78
+ # report the "index|exit status" is redirected to FD#3.
79
+ # Each command's STDOUT will be connected to the STDIN of the next subshell.
80
+ # The entire pipeline is run within a container group, which redirects
81
+ # FD#3 to STDOUT and FD#4 to STDERR so these can be collected.
82
+ # FD#1 is redirected to STDERR so that any output from the final command
83
+ # on STDOUT will generate warnings, since the final command should not
84
+ # attempt to write to STDOUT, as this would interfere with collecting
85
+ # the exit statuses.
86
+ #
87
+ # There is no guarantee as to the order of this output, which is why the
88
+ # command's index in @commands is passed along with it's exit status.
89
+ # And, if multiple commands output messages on STDERR, those messages
90
+ # may be interleaved. Interleaving of the "index|exit status" outputs
91
+ # should not be an issue, given the small byte size of the data being written.
92
+ def pipeline
93
+ parts = []
94
+ @commands.each_with_index do |command, index|
95
+ parts << %Q[{ #{ command } 2>&4 ; echo "#{ index }|$?:" >&3 ; }]
96
+ end
97
+ %Q[{ #{ parts.join(' | ') } } 3>&1 1>&2 4>&2]
98
+ end
99
+
100
+ def stderr_messages
101
+ @stderr_messages ||= @stderr.empty? ? false : <<-EOS.gsub(/^ +/, ' ')
102
+ Pipeline STDERR Messages:
103
+ (Note: may be interleaved if multiple commands returned error messages)
104
+
105
+ #{ @stderr }
106
+ EOS
107
+ end
108
+
109
+ end
110
+ end
@@ -0,0 +1,99 @@
1
+ # encoding: utf-8
2
+
3
+ module WordpressDeploy
4
+ module Storage
5
+ class Base
6
+ include WordpressDeploy::Configuration::Helpers
7
+
8
+ ##
9
+ # Sets the limit to how many backups to keep in the remote location.
10
+ # If exceeded, the oldest will be removed to make room for the newest
11
+ attr_accessor :keep
12
+
13
+ ##
14
+ # (Optional)
15
+ # User-defined string used to uniquely identify multiple storages of the
16
+ # same type. This will be appended to the YAML storage file used for
17
+ # cycling backups.
18
+ attr_accessor :storage_id
19
+
20
+ ##
21
+ # Creates a new instance of the storage object
22
+ # * Called with super(model, storage_id) from each subclass
23
+ def initialize(model, storage_id = nil)
24
+ load_defaults!
25
+ @model = model
26
+ @storage_id = storage_id
27
+ end
28
+
29
+ ##
30
+ # Performs the backup transfer
31
+ def perform!
32
+ @package = @model.package
33
+ transfer!
34
+ cycle!
35
+ end
36
+
37
+ private
38
+
39
+ ##
40
+ # Provider defaults to false. Overridden when using a service-based
41
+ # storage such as Amazon S3, Rackspace Cloud Files or Dropbox
42
+ def provider
43
+ false
44
+ end
45
+
46
+ ##
47
+ # Each subclass must define a +path+ where remote files will be stored
48
+ def path; end
49
+
50
+ ##
51
+ # Return the storage name, with optional storage_id
52
+ def storage_name
53
+ self.class.to_s.sub('WordpressDeploy::', '') +
54
+ (storage_id ? " (#{storage_id})" : '')
55
+ end
56
+
57
+ ##
58
+ # Returns the local path
59
+ # This is where any Package to be transferred is located.
60
+ def local_path
61
+ Config.tmp_path
62
+ end
63
+
64
+ ##
65
+ # Returns the remote path for the given Package
66
+ # This is where the Package will be stored, or was previously stored.
67
+ def remote_path_for(package)
68
+ File.join(path, package.trigger, package.time)
69
+ end
70
+
71
+ ##
72
+ # Yields two arguments to the given block: "local_file, remote_file"
73
+ # The local_file is the full file name:
74
+ # e.g. "2011.08.30.11.00.02.backup.tar.enc"
75
+ # The remote_file is the full file name, minus the timestamp:
76
+ # e.g. "backup.tar.enc"
77
+ def files_to_transfer_for(package)
78
+ package.filenames.each do |filename|
79
+ yield filename, filename[20..-1]
80
+ end
81
+ end
82
+ alias :transferred_files_for :files_to_transfer_for
83
+
84
+ ##
85
+ # Adds the current package being stored to the YAML cycle data file
86
+ # and will remove any old Package file(s) when the storage limit
87
+ # set by #keep is exceeded. Any errors raised while attempting to
88
+ # remove older packages will be rescued and a warning will be logged
89
+ # containing the original error message.
90
+ def cycle!
91
+ return unless keep.to_i > 0
92
+ Logger.message "#{ storage_name }: Cycling Started..."
93
+ Cycler.cycle!(self, @package)
94
+ Logger.message "#{ storage_name }: Cycling Complete!"
95
+ end
96
+
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,133 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Only load the Net::FTP library/gem when the WordpressDeploy::Storage::FTP class is loaded
5
+ require 'net/ftp'
6
+ require 'pathname'
7
+ require 'action_view'
8
+
9
+ module WordpressDeploy
10
+ module Storage
11
+ class FTP < Base
12
+ include ActionView::Helpers::NumberHelper
13
+
14
+ ##
15
+ # Server credentials
16
+ attr_accessor :username, :password
17
+
18
+ ##
19
+ # Server IP Address and FTP port
20
+ attr_accessor :ip, :port
21
+ alias :hostname :ip
22
+
23
+ ##
24
+ # Path to store backups to
25
+ attr_accessor :path
26
+
27
+ ##
28
+ # use passive mode?
29
+ attr_accessor :passive_mode
30
+
31
+ ##
32
+ # the remote path
33
+ attr_accessor :remote_path
34
+
35
+ ##
36
+ # Creates a new instance of the storage object
37
+ def initialize(model, storage_id = nil, &block)
38
+ super(model, storage_id)
39
+
40
+ @hostname = hostname
41
+ @username = username
42
+ @password = password
43
+ @remote_path = remote_path
44
+ @remote_directores = []
45
+
46
+ @port ||= 21
47
+ @path ||= 'backups'
48
+ @passive_mode ||= false
49
+
50
+ instance_eval(&block) if block_given?
51
+
52
+ @path = path.sub(/^\~\//, '')
53
+ end
54
+
55
+ private
56
+
57
+ ##
58
+ # Establishes a connection to the remote server
59
+ #
60
+ # Note:
61
+ # Since the FTP port is defined as a constant in the Net::FTP class, and
62
+ # might be required to change by the user, we dynamically remove and
63
+ # re-add the constant with the provided port value
64
+ def connection
65
+ if Net::FTP.const_defined?(:FTP_PORT)
66
+ Net::FTP.send(:remove_const, :FTP_PORT)
67
+ end; Net::FTP.send(:const_set, :FTP_PORT, port)
68
+
69
+ Net::FTP.open(ip, username, password) do |ftp|
70
+ ftp.passive = true if passive_mode
71
+ yield ftp
72
+ end
73
+ end
74
+
75
+ ##
76
+ # Transfers the archived file to the specified remote server
77
+ def transfer!
78
+ remote_path = remote_path_for(@package)
79
+
80
+ connection do |ftp|
81
+ create_remote_path(remote_path, ftp)
82
+
83
+ files_to_transfer_for(@package) do |local_file, remote_file|
84
+ Logger.message "#{storage_name} started transferring " +
85
+ "'#{ local_file }' to '#{ ip }'."
86
+ ftp.put(
87
+ File.join(local_path, local_file),
88
+ File.join(remote_path, remote_file)
89
+ )
90
+ end
91
+ end
92
+ end
93
+
94
+ ##
95
+ # Removes the transferred archive file(s) from the storage location.
96
+ # Any error raised will be rescued during Cycling
97
+ # and a warning will be logged, containing the error message.
98
+ def remove!(package)
99
+ remote_path = remote_path_for(package)
100
+
101
+ connection do |ftp|
102
+ transferred_files_for(package) do |local_file, remote_file|
103
+ Logger.message "#{storage_name} started removing " +
104
+ "'#{ local_file }' from '#{ ip }'."
105
+
106
+ ftp.delete(File.join(remote_path, remote_file))
107
+ end
108
+
109
+ ftp.rmdir(remote_path)
110
+ end
111
+ end
112
+
113
+ ##
114
+ # Creates (if they don't exist yet) all the directories on the remote
115
+ # server in order to upload the backup file. Net::FTP does not support
116
+ # paths to directories that don't yet exist when creating new
117
+ # directories. Instead, we split the parts up in to an array (for each
118
+ # '/') and loop through that to create the directories one by one.
119
+ # Net::FTP raises an exception when the directory it's trying to create
120
+ # already exists, so we have rescue it
121
+ def create_remote_path(remote_path, ftp)
122
+ path_parts = Array.new
123
+ remote_path.split('/').each do |path_part|
124
+ path_parts << path_part
125
+ begin
126
+ ftp.mkdir(path_parts.join('/'))
127
+ rescue Net::FTPPermError; end
128
+ end
129
+ end
130
+
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,82 @@
1
+ # encoding: utf-8
2
+
3
+ module WordpressDeploy
4
+ module Storage
5
+ class Local < Base
6
+
7
+ ##
8
+ # Path where the backup will be stored.
9
+ attr_accessor :path
10
+
11
+ ##
12
+ # Creates a new instance of the storage object
13
+ def initialize(model, storage_id = nil, &block)
14
+ super(model, storage_id)
15
+
16
+ @path ||= File.join(
17
+ File.expand_path(ENV['HOME'] || ''),
18
+ 'backups'
19
+ )
20
+
21
+ instance_eval(&block) if block_given?
22
+
23
+ @path = File.expand_path(@path)
24
+ end
25
+
26
+ private
27
+
28
+ ##
29
+ # Transfers the archived file to the specified path
30
+ def transfer!
31
+ remote_path = remote_path_for(@package)
32
+ FileUtils.mkdir_p(remote_path)
33
+
34
+ files_to_transfer_for(@package) do |local_file, remote_file|
35
+ Logger.message "#{storage_name} started transferring '#{ local_file }'."
36
+
37
+ src_path = File.join(local_path, local_file)
38
+ dst_path = File.join(remote_path, remote_file)
39
+ FileUtils.send(transfer_method, src_path, dst_path)
40
+ end
41
+ end
42
+
43
+ ##
44
+ # Removes the transferred archive file(s) from the storage location.
45
+ # Any error raised will be rescued during Cycling
46
+ # and a warning will be logged, containing the error message.
47
+ def remove!(package)
48
+ remote_path = remote_path_for(package)
49
+
50
+ messages = []
51
+ transferred_files_for(package) do |local_file, remote_file|
52
+ messages << "#{storage_name} started removing '#{ local_file }'."
53
+ end
54
+ Logger.message messages.join("\n")
55
+
56
+ FileUtils.rm_r(remote_path)
57
+ end
58
+
59
+ ##
60
+ # Set and return the transfer method.
61
+ # If this Local Storage is not the last Storage for the Model,
62
+ # force the transfer to use a *copy* operation and issue a warning.
63
+ def transfer_method
64
+ return @transfer_method if @transfer_method
65
+
66
+ if self == @model.storages.last
67
+ @transfer_method = :mv
68
+ else
69
+ Logger.warn Errors::Storage::Local::TransferError.new(<<-EOS)
70
+ Local File Copy Warning!
71
+ The final backup file(s) for '#{@model.label}' (#{@model.trigger})
72
+ will be *copied* to '#{remote_path_for(@package)}'
73
+ To avoid this, when using more than one Storage, the 'Local' Storage
74
+ should be added *last* so the files may be *moved* to their destination.
75
+ EOS
76
+ @transfer_method = :cp
77
+ end
78
+ end
79
+
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,99 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # Only load the Net::SSH and Net::SCP library/gems
5
+ # when the WordpressDeploy::Storage::SCP class is loaded
6
+ WordpressDeploy::Dependency.load('net-ssh')
7
+ WordpressDeploy::Dependency.load('net-scp')
8
+
9
+ module WordpressDeploy
10
+ module Storage
11
+ class SCP < Base
12
+
13
+ ##
14
+ # Server credentials
15
+ attr_accessor :username, :password
16
+
17
+ ##
18
+ # Server IP Address and SCP port
19
+ attr_accessor :ip, :port
20
+
21
+ ##
22
+ # Path to store backups to
23
+ attr_accessor :path
24
+
25
+ ##
26
+ # Creates a new instance of the storage object
27
+ def initialize(model, storage_id = nil, &block)
28
+ super(model, storage_id)
29
+
30
+ @port ||= 22
31
+ @path ||= 'backups'
32
+
33
+ instance_eval(&block) if block_given?
34
+
35
+ @path = path.sub(/^\~\//, '')
36
+ end
37
+
38
+ private
39
+
40
+ ##
41
+ # Establishes a connection to the remote server
42
+ # and yields the Net::SSH connection.
43
+ # Net::SCP will use this connection to transfer backups
44
+ def connection
45
+ Net::SSH.start(
46
+ ip, username, :password => password, :port => port
47
+ ) {|ssh| yield ssh }
48
+ end
49
+
50
+ ##
51
+ # Transfers the archived file to the specified remote server
52
+ def transfer!
53
+ remote_path = remote_path_for(@package)
54
+
55
+ connection do |ssh|
56
+ ssh.exec!("mkdir -p '#{ remote_path }'")
57
+
58
+ files_to_transfer_for(@package) do |local_file, remote_file|
59
+ Logger.message "#{storage_name} started transferring " +
60
+ "'#{local_file}' to '#{ip}'."
61
+
62
+ ssh.scp.upload!(
63
+ File.join(local_path, local_file),
64
+ File.join(remote_path, remote_file)
65
+ )
66
+ end
67
+ end
68
+ end
69
+
70
+ ##
71
+ # Removes the transferred archive file(s) from the storage location.
72
+ # Any error raised will be rescued during Cycling
73
+ # and a warning will be logged, containing the error message.
74
+ def remove!(package)
75
+ remote_path = remote_path_for(package)
76
+
77
+ messages = []
78
+ transferred_files_for(package) do |local_file, remote_file|
79
+ messages << "#{storage_name} started removing " +
80
+ "'#{local_file}' from '#{ip}'."
81
+ end
82
+ Logger.message messages.join("\n")
83
+
84
+ errors = []
85
+ connection do |ssh|
86
+ ssh.exec!("rm -r '#{remote_path}'") do |ch, stream, data|
87
+ errors << data if stream == :stderr
88
+ end
89
+ end
90
+ unless errors.empty?
91
+ raise Errors::Storage::SCP::SSHError,
92
+ "Net::SSH reported the following errors:\n" +
93
+ errors.join("\n")
94
+ end
95
+ end
96
+
97
+ end
98
+ end
99
+ end