wordpress-deploy 1.0.0.alpha1

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.
@@ -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