bootscript 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/.gitignore +17 -0
- data/.rspec +1 -0
- data/ERB_VARS.md +30 -0
- data/Gemfile +2 -0
- data/LICENSE.txt +22 -0
- data/README.md +176 -0
- data/Rakefile +8 -0
- data/bootscript.gemspec +31 -0
- data/lib/bootscript.rb +80 -0
- data/lib/bootscript/chef.rb +70 -0
- data/lib/bootscript/script.rb +178 -0
- data/lib/bootscript/uu_writer.rb +17 -0
- data/lib/bootscript/version.rb +3 -0
- data/lib/templates/bootscript.ps1.erb +91 -0
- data/lib/templates/bootscript.sh.erb +154 -0
- data/lib/templates/chef/attributes.json.erb +7 -0
- data/lib/templates/chef/chef-install.ps1.erb +126 -0
- data/lib/templates/chef/chef-install.sh.erb +72 -0
- data/lib/templates/chef/chef_client.conf.erb +9 -0
- data/lib/templates/chef/json_attributes.rb.erb +4 -0
- data/lib/templates/chef/ramdisk_secrets.rb.erb +2 -0
- data/lib/templates/windows_footer.bat.erb +4 -0
- data/lib/templates/windows_header.bat.erb +18 -0
- data/spec/bootscript/chef_spec.rb +48 -0
- data/spec/bootscript/script_spec.rb +126 -0
- data/spec/bootscript/uu_writer_spec.rb +30 -0
- data/spec/bootscript_spec.rb +71 -0
- data/spec/spec_helper.rb +8 -0
- data/spec/unpacker.rb +40 -0
- metadata +208 -0
@@ -0,0 +1,178 @@
|
|
1
|
+
module Bootscript
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
require 'erubis'
|
5
|
+
require 'json'
|
6
|
+
require 'tmpdir'
|
7
|
+
require 'zlib'
|
8
|
+
require 'archive/tar/minitar'
|
9
|
+
require 'zip'
|
10
|
+
|
11
|
+
# Main functional class. Models and builds a self-extracting Bash/TAR file.
|
12
|
+
class Script
|
13
|
+
|
14
|
+
# A Hash of data sources to be written onto the boot target's filesystem.
|
15
|
+
# Each (String) key is a path to the desired file on the boot target.
|
16
|
+
# Each value can be a String (treated as ERB), or Object with a read method.
|
17
|
+
# Any Ruby File objects with extension ".ERB" are also processed as ERB.
|
18
|
+
attr_accessor :data_map
|
19
|
+
|
20
|
+
# Standard Ruby Logger, overridden by passing :logger to {#initialize}
|
21
|
+
attr_reader :log
|
22
|
+
|
23
|
+
# constructor - configures the AWS S3 connection and logging
|
24
|
+
# @param logger [::Logger] - a standard Ruby logger
|
25
|
+
def initialize(logger = nil)
|
26
|
+
@log ||= logger || Bootscript.default_logger
|
27
|
+
@data_map = Hash.new
|
28
|
+
@vars = Hash.new
|
29
|
+
end
|
30
|
+
|
31
|
+
# Generates the BootScript contents by interpreting the @data_map
|
32
|
+
# based on erb_vars. If destination has a write() method,
|
33
|
+
# the data is streamed there line-by-line, and the number of bytes written
|
34
|
+
# is returned. Otherwise, the BootScript contents are returned as a String.
|
35
|
+
# In the case of streaming output, the destination must be already opened.
|
36
|
+
# @param erb_vars [Hash] Ruby variables to interpolate into all templates
|
37
|
+
# @param destination [IO] a Ruby object that responds to write(String)
|
38
|
+
# @return [Fixnum] the number of bytes written to the destination, or
|
39
|
+
# @return [String] the text of the rendered script, if destination is nil
|
40
|
+
def generate(erb_vars = {}, destination = nil)
|
41
|
+
# Set state / instance variables, used by publish() and helper methods
|
42
|
+
@vars = Bootscript.merge_platform_defaults(erb_vars)
|
43
|
+
output = destination || StringIO.open(@script_data = "")
|
44
|
+
@bytes_written = 0
|
45
|
+
if Bootscript.windows?(@vars)
|
46
|
+
@bytes_written += output.write(render_erb_text(File.read(
|
47
|
+
"#{File.dirname(__FILE__)}/../templates/windows_header.bat.erb"
|
48
|
+
)))
|
49
|
+
end
|
50
|
+
write_bootscript(output) # streams the script part line-by-line
|
51
|
+
write_uuencoded_archive(output) # streams the archive line-by-line
|
52
|
+
if Bootscript.windows?(@vars)
|
53
|
+
@bytes_written += output.write(render_erb_text(File.read(
|
54
|
+
"#{File.dirname(__FILE__)}/../templates/windows_footer.bat.erb"
|
55
|
+
)))
|
56
|
+
end
|
57
|
+
output.close unless destination # (close StringIO if it was opened)
|
58
|
+
return (destination ? @bytes_written : @script_data)
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
# Streams the bootscript to destination and updates @bytes_written
|
64
|
+
def write_bootscript(destination)
|
65
|
+
# If streaming, send the top-level script line-by-line from memory
|
66
|
+
if Bootscript.windows?(@vars)
|
67
|
+
template_path = Bootscript::WINDOWS_TEMPLATE
|
68
|
+
else
|
69
|
+
template_path = Bootscript::UNIX_TEMPLATE
|
70
|
+
end
|
71
|
+
template = File.read(template_path)
|
72
|
+
template = strip_shell_comments(template) if @vars[:strip_comments]
|
73
|
+
@log.debug "Rendering boot script to #{destination}..."
|
74
|
+
render_erb_text(template).each_line do |ln|
|
75
|
+
destination.write ln
|
76
|
+
@bytes_written += ln.bytes.count
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Streams the uuencoded archive to destination, updating @bytes_written
|
81
|
+
def write_uuencoded_archive(destination)
|
82
|
+
@log.debug "Writing #{@vars[:platform]} archive to #{destination}..."
|
83
|
+
if Bootscript.windows?(@vars)
|
84
|
+
@bytes_written += destination.write("$archive = @'\n")
|
85
|
+
write_windows_archive(destination)
|
86
|
+
@bytes_written += destination.write("'@\n")
|
87
|
+
else # :platform = 'unix'
|
88
|
+
@bytes_written += destination.write("begin-base64 0600 bootstrap.tbz\n")
|
89
|
+
write_unix_archive(destination)
|
90
|
+
@bytes_written += destination.write("====\n") # (base64 footer)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Streams a uuencoded TGZ archive to destination, updating @bytes_written
|
95
|
+
def write_unix_archive(destination)
|
96
|
+
begin
|
97
|
+
uuencode = UUWriter.new(destination)
|
98
|
+
gz = Zlib::GzipWriter.new(uuencode)
|
99
|
+
tar = Archive::Tar::Minitar::Writer.open(gz)
|
100
|
+
render_data_map_into(tar)
|
101
|
+
ensure
|
102
|
+
tar.close
|
103
|
+
gz.close
|
104
|
+
@bytes_written += uuencode.bytes_written
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Streams a uuencoded ZIP archive to destination, updating @bytes_written
|
109
|
+
def write_windows_archive(destination)
|
110
|
+
Dir.mktmpdir do |dir|
|
111
|
+
zip_path = "#{dir}/archive.zip"
|
112
|
+
zipfile = File.open(zip_path, 'wb')
|
113
|
+
Zip::OutputStream.open(zipfile) {|zip| render_data_map_into(zip)}
|
114
|
+
zipfile.close
|
115
|
+
@log.debug "zipfile = #{zip_path}, length = #{File.size zip_path}"
|
116
|
+
File.open(zip_path, 'rb') do |zipfile|
|
117
|
+
@bytes_written += destination.write([zipfile.read].pack 'm')
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# renders each data map item into an 'archive', which must be either an
|
123
|
+
# Archive::Tar::Minitar::Writer (if unix), or a Zip::OutputStream (windows)
|
124
|
+
def render_data_map_into(archive)
|
125
|
+
full_data_map.each do |remote_path, item|
|
126
|
+
if item.is_a? String # case 1: data item is a String
|
127
|
+
@log.debug "Rendering ERB data (#{item[0..16]}...) into archive"
|
128
|
+
data = render_erb_text(item)
|
129
|
+
input = StringIO.open(data, 'r')
|
130
|
+
size = data.bytes.count
|
131
|
+
elsif item.is_a?(File) # case 2: data item is an ERB file
|
132
|
+
if item.path.upcase.sub(/\A.*\./,'') == 'ERB'
|
133
|
+
@log.debug "Rendering ERB file #{item.path} into archive"
|
134
|
+
data = render_erb_text(item.read)
|
135
|
+
input = StringIO.open(data, 'r')
|
136
|
+
size = data.bytes.count
|
137
|
+
else # case 3: data item is a regular File
|
138
|
+
@log.debug "Copying data from #{item.inspect} into archive"
|
139
|
+
input = item
|
140
|
+
size = File.stat(item).size
|
141
|
+
end
|
142
|
+
else # case 4: Error
|
143
|
+
raise ArgumentError.new("cannot process item: #{item}")
|
144
|
+
end
|
145
|
+
if Bootscript.windows?(@vars)
|
146
|
+
archive.put_next_entry remote_path
|
147
|
+
archive.write input.read
|
148
|
+
else
|
149
|
+
opts = {mode: 0600, size: size, mtime: Time.now}
|
150
|
+
archive.add_file_simple(remote_path, opts) do |output|
|
151
|
+
while data = input.read(512) ; output.write data end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# merges the @data_map with the Chef built-ins, as-needed
|
158
|
+
def full_data_map
|
159
|
+
Bootscript::Chef.included?(@vars) ?
|
160
|
+
@data_map.merge(Bootscript::Chef.files(@vars)) : @data_map
|
161
|
+
end
|
162
|
+
|
163
|
+
# renders erb_text, using @vars
|
164
|
+
def render_erb_text(erb_text)
|
165
|
+
Erubis::Eruby.new(erb_text).result(@vars)
|
166
|
+
end
|
167
|
+
|
168
|
+
# strips all empty lines and lines beginning with # from text
|
169
|
+
# does NOT touch the first line of text
|
170
|
+
def strip_shell_comments(text)
|
171
|
+
lines = text.lines.to_a
|
172
|
+
return text if lines.count < 2
|
173
|
+
lines.first + lines[1..lines.count].
|
174
|
+
reject{|l| (l =~ /^\s*#/) || (l =~ /^\s+$/)}.join('')
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
$startup_cmd = '<%= startup_command %>'
|
2
|
+
$createRAMDisk = $<%= create_ramdisk.to_s.upcase %>
|
3
|
+
$RAMDiskSize = "<%= ramdisk_size %>M"
|
4
|
+
$RAMDiskMount = "<%= ramdisk_mount %>"
|
5
|
+
$IMDiskURL = "<%= imdisk_url %>"
|
6
|
+
$IMDisk = "C:\windows\system32\imdisk.exe"
|
7
|
+
$IMDIskInstaller = "C:\imdiskinst.exe"
|
8
|
+
|
9
|
+
function main()
|
10
|
+
{
|
11
|
+
echo "Starting bootstrap..."
|
12
|
+
Decrypt-Archive
|
13
|
+
if ($createRAMDisk)
|
14
|
+
{
|
15
|
+
Download-ImDisk
|
16
|
+
Install-ImDisk
|
17
|
+
Execute-ImDisk
|
18
|
+
echo "RAMDisk setup complete."
|
19
|
+
}
|
20
|
+
Execute-Command($startup_cmd)
|
21
|
+
echo "Bootstrap complete."
|
22
|
+
}
|
23
|
+
|
24
|
+
function Download-ImDisk()
|
25
|
+
{
|
26
|
+
if ((test-path $IMDIskInstaller) -ne $true)
|
27
|
+
{
|
28
|
+
echo "Downloading ImDisk utility..."
|
29
|
+
$wc = new-object System.Net.WebClient
|
30
|
+
try { $wc.DownloadFile($IMDiskURL, $IMDIskInstaller) }
|
31
|
+
catch { throw $error[0] }
|
32
|
+
}
|
33
|
+
}
|
34
|
+
|
35
|
+
function Install-ImDisk()
|
36
|
+
{
|
37
|
+
if ((test-path $IMDisk) -ne $true)
|
38
|
+
{
|
39
|
+
$Env:IMDISK_SILENT_SETUP = 1
|
40
|
+
Execute-Command("$IMDIskInstaller -y")
|
41
|
+
}
|
42
|
+
}
|
43
|
+
|
44
|
+
function Execute-ImDisk()
|
45
|
+
{
|
46
|
+
if ((test-path $RAMDiskMount) -ne $true)
|
47
|
+
{
|
48
|
+
echo "Setting up $RAMDiskSize RAMDisk at $RAMDiskMount..."
|
49
|
+
$fsArgs = '"/fs:ntfs /q /y"'
|
50
|
+
Execute-Command("$IMDisk -a -t vm -m $RAMDiskMount -s $RAMDiskSize -p $fsArgs")
|
51
|
+
}
|
52
|
+
}
|
53
|
+
|
54
|
+
function Decrypt-Archive()
|
55
|
+
{
|
56
|
+
$archive_path = "C:\bootscript_archive.zip"
|
57
|
+
[io.file]::WriteAllBytes($archive_path,
|
58
|
+
[System.Convert]::FromBase64String($archive))
|
59
|
+
Expand-ZIPFile $archive_path "C:\"
|
60
|
+
Remove-Item $archive_path
|
61
|
+
}
|
62
|
+
|
63
|
+
function Expand-ZIPFile($file, $dest)
|
64
|
+
{
|
65
|
+
$shell = new-object -com shell.application
|
66
|
+
$zip = $shell.NameSpace($file)
|
67
|
+
foreach($item in $zip.items())
|
68
|
+
{ $shell.Namespace($dest).copyhere($item) }
|
69
|
+
}
|
70
|
+
|
71
|
+
function Execute-Command($cmd)
|
72
|
+
{
|
73
|
+
if ($cmd -ne "")
|
74
|
+
{
|
75
|
+
Try {
|
76
|
+
echo "Running: $cmd"
|
77
|
+
$Env:_THIS_CMD = $cmd
|
78
|
+
$proc = Start-Process -FilePath c:\windows\system32\cmd.exe `
|
79
|
+
-ArgumentList "/C", "%_THIS_CMD%" `
|
80
|
+
-Verbose -Debug -Wait -Passthru
|
81
|
+
do { start-sleep -Milliseconds 500 }
|
82
|
+
until ($proc.HasExited)
|
83
|
+
echo "Finished: $cmd"
|
84
|
+
}
|
85
|
+
Catch {
|
86
|
+
echo "Failed: $cmd"
|
87
|
+
echo "Error was: $error[0]"
|
88
|
+
throw $error[0]
|
89
|
+
}
|
90
|
+
}
|
91
|
+
}
|
@@ -0,0 +1,154 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
# Top-level bootscript
|
3
|
+
set -e # always stop on errors
|
4
|
+
test $UID == 0 || (echo "ERROR: must run as root"; exit 1)
|
5
|
+
|
6
|
+
####################################
|
7
|
+
#### Step 0 - Configuration
|
8
|
+
UPDATE_OS='<%= update_os %>'
|
9
|
+
CREATE_RAMDISK='<%= create_ramdisk %>'
|
10
|
+
RAMDISK_SIZE='<%= ramdisk_size %>'
|
11
|
+
RAMDISK_MOUNT='<%= ramdisk_mount %>'
|
12
|
+
LOG="/var/log/bootscript.log"
|
13
|
+
|
14
|
+
|
15
|
+
####################################
|
16
|
+
#### Step 1 - Logging control
|
17
|
+
# Log all output to the log file, in addition to STDOUT
|
18
|
+
npipe=/tmp/$$.tmp
|
19
|
+
trap "rm -f $npipe" EXIT
|
20
|
+
mknod $npipe p
|
21
|
+
tee <$npipe $LOG &
|
22
|
+
exec 1>&-
|
23
|
+
exec 1>$npipe 2>&1
|
24
|
+
|
25
|
+
|
26
|
+
####################################
|
27
|
+
#### STEP 2 - Detect linux distro
|
28
|
+
lowercase(){
|
29
|
+
echo "$1" |
|
30
|
+
sed "y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/"
|
31
|
+
}
|
32
|
+
OS=`lowercase \`uname\``
|
33
|
+
KERNEL=`uname -r`
|
34
|
+
MACH=`uname -m`
|
35
|
+
if [ "{$OS}" == "windowsnt" ]; then
|
36
|
+
OS=windows
|
37
|
+
elif [ "{$OS}" == "darwin" ]; then
|
38
|
+
OS=mac
|
39
|
+
else
|
40
|
+
OS=`uname`
|
41
|
+
if [ "${OS}" = "SunOS" ] ; then
|
42
|
+
OS=Solaris
|
43
|
+
ARCH=`uname -p`
|
44
|
+
OSSTR="${OS} ${REV}(${ARCH} `uname -v`)"
|
45
|
+
elif [ "${OS}" = "AIX" ] ; then
|
46
|
+
OSSTR="${OS} `oslevel` (`oslevel -r`)"
|
47
|
+
elif [ "${OS}" = "Linux" ] ; then
|
48
|
+
if [ -f /etc/redhat-release ] ; then
|
49
|
+
DistroBasedOn='RedHat'
|
50
|
+
DIST=`cat /etc/redhat-release |sed s/\ release.*//`
|
51
|
+
PSUEDONAME=`cat /etc/redhat-release | sed s/.*\(// | sed s/\)//`
|
52
|
+
REV=`cat /etc/redhat-release | sed s/.*release\ // | sed s/\ .*//`
|
53
|
+
elif [ -f /etc/SuSE-release ] ; then
|
54
|
+
DistroBasedOn='SuSe'
|
55
|
+
PSUEDONAME=`cat /etc/SuSE-release | tr "\n" ' '| sed s/VERSION.*//`
|
56
|
+
REV=`cat /etc/SuSE-release | tr "\n" ' ' | sed s/.*=\ //`
|
57
|
+
elif [ -f /etc/mandrake-release ] ; then
|
58
|
+
DistroBasedOn='Mandrake'
|
59
|
+
PSUEDONAME=`cat /etc/mandrake-release | sed s/.*\(// | sed s/\)//`
|
60
|
+
REV=`cat /etc/mandrake-release | sed s/.*release\ // | sed s/\ .*//`
|
61
|
+
elif [ -f /etc/debian_version ] ; then
|
62
|
+
DistroBasedOn='Debian'
|
63
|
+
DIST=`cat /etc/lsb-release | grep '^DISTRIB_ID' | awk -F= '{ print $2 }'`
|
64
|
+
PSUEDONAME=`cat /etc/lsb-release | grep '^DISTRIB_CODENAME' | awk -F= '{ print $2 }'`
|
65
|
+
REV=`cat /etc/lsb-release | grep '^DISTRIB_RELEASE' | awk -F= '{ print $2 }'`
|
66
|
+
fi
|
67
|
+
if [ -f /etc/UnitedLinux-release ] ; then
|
68
|
+
DIST="${DIST}[`cat /etc/UnitedLinux-release | tr "\n" ' ' | sed s/VERSION.*//`]"
|
69
|
+
fi
|
70
|
+
OS=`lowercase $OS`
|
71
|
+
DistroBasedOn=`lowercase $DistroBasedOn`
|
72
|
+
readonly OS
|
73
|
+
readonly DIST
|
74
|
+
readonly DistroBasedOn
|
75
|
+
readonly PSUEDONAME
|
76
|
+
readonly REV
|
77
|
+
readonly KERNEL
|
78
|
+
readonly MACH
|
79
|
+
fi
|
80
|
+
fi
|
81
|
+
|
82
|
+
####################################
|
83
|
+
#### STEP 3 - OS update
|
84
|
+
if [ "$DistroBasedOn" == 'debian' ] ; then
|
85
|
+
export DEBIAN_FRONTEND=noninteractive
|
86
|
+
if [ "$UPDATE_OS" == 'true' ] ; then
|
87
|
+
echo "Upgrading all OS packages..."
|
88
|
+
apt-get update
|
89
|
+
cmd='apt-get -o Dpkg::Options::="--force-all" --force-yes -y upgrade'
|
90
|
+
$cmd || echo "==> WARNING: Unable to complete operating system package upgrade!"
|
91
|
+
fi
|
92
|
+
fi
|
93
|
+
|
94
|
+
####################################
|
95
|
+
#### STEP 4 - Create RAMdisk
|
96
|
+
if [ "$CREATE_RAMDISK" == 'true' ] ; then
|
97
|
+
if $(mount | grep "$RAMDISK_MOUNT" >/dev/null 2>&1) ; then
|
98
|
+
echo "RAMdisk already exists at ${RAMDISK_MOUNT}..."
|
99
|
+
else
|
100
|
+
echo "Creating $RAMDISK_SIZE RAMdisk at ${RAMDISK_MOUNT}..."
|
101
|
+
if [ -e $RAMDISK_MOUNT ] ; then
|
102
|
+
echo "ERROR: $RAMDISK_MOUNT already exists and is not a RAMdisk!"
|
103
|
+
exit 4
|
104
|
+
fi
|
105
|
+
mkdir -p $RAMDISK_MOUNT
|
106
|
+
mount -t tmpfs -o size=${RAMDISK_SIZE}M tmpfs $RAMDISK_MOUNT
|
107
|
+
fi
|
108
|
+
fi
|
109
|
+
|
110
|
+
|
111
|
+
####################################
|
112
|
+
#### STEP 5 - Check for uudecode, and attempt to install it if needed
|
113
|
+
if ! (which uudecode >/dev/null 2>&1) ; then
|
114
|
+
flavor="$OS / $Dist / $DistroBasedOn"
|
115
|
+
echo "uudecode not found - will attempt installation for $flavor"
|
116
|
+
if [ "$OS" == 'linux' ] ; then
|
117
|
+
if [ "$DistroBasedOn" == 'debian' ] ; then
|
118
|
+
apt-get -y install sharutils
|
119
|
+
else
|
120
|
+
echo "ERROR: Only Debian-derived Linux supported for now :("
|
121
|
+
exit 3
|
122
|
+
fi
|
123
|
+
fi
|
124
|
+
fi
|
125
|
+
|
126
|
+
|
127
|
+
####################################
|
128
|
+
#### STEP 6 - Extract Archive
|
129
|
+
# Cut the trailing part of this file and pipe it to uudecode and tar
|
130
|
+
echo "Extracting the included tar archive..."
|
131
|
+
SCRIPT_PATH="$( cd "$(dirname "$0")" ; pwd -P )/$(basename $0)"
|
132
|
+
ARCHIVE=`awk '/^__ARCHIVE_FOLLOWS__/ {print NR + 1; exit 0; }' $SCRIPT_PATH`
|
133
|
+
cd /
|
134
|
+
tail -n+$ARCHIVE $SCRIPT_PATH | uudecode -o /dev/stdout | tar xovz
|
135
|
+
echo "Removing ${SCRIPT_PATH}..."
|
136
|
+
rm -f $SCRIPT_PATH # this script removes itself!
|
137
|
+
|
138
|
+
|
139
|
+
<% if defined? startup_command %>
|
140
|
+
####################################
|
141
|
+
#### STEP 7 - Execute startup command
|
142
|
+
echo "Executing user startup command..."
|
143
|
+
<% if defined? chef_validation_pem %>
|
144
|
+
chmod 0744 /usr/local/sbin/chef-install.sh
|
145
|
+
<% end %>
|
146
|
+
exec <%= startup_command %>
|
147
|
+
<% end %>
|
148
|
+
exit 0 # This is reached only if there's no user startup command
|
149
|
+
|
150
|
+
|
151
|
+
####################################
|
152
|
+
#### Boot data archive - this gets extracted with `tail`, and
|
153
|
+
#### piped through `uudecode` and `tar` in Step 5, above
|
154
|
+
__ARCHIVE_FOLLOWS__
|