Showing posts with label nightly updates. Show all posts
Showing posts with label nightly updates. Show all posts

Tuesday, July 11, 2006

A General-Purpose Nightly Update Script

This is the fourth (and last) in a series of posts about a simple nightly update system for OS X.

In previous posts I've described Apple's "softwareupdate" command, which can be used to keep Apple-supplied software up-to-date, and I've described a set of scripts that can be used to keep third-party packages (at least if they're packaged in certain formats) up-to-date. These are necessary components of a nightly update system, but we still need more.

We'd also like to accomodate software that isn't packaged. For example, scripts we've written locally, or software that we've installed from source. For this purpose I've traditionally kept a couple of local directory trees that are synchronized nightly with master copies living on a server.

In addition, we'd like to keep certain local configuration files in sync with master copies. For example, we might want to com.apple.loginwindow.plist the same on all machines, and be able to change this file on all machines from time to time.

But there are other tasks besides software updates and configuration file updates. To accomodate these, we'd like the ability to run one or more catchall scripts on a nightly basis. The scripts should be syncronized with master copies living on a server, and can be used to perform whatever tasks we find necessary on a given night.

To do all of this I've written the following script, called just "update", which is based on an analogous script we've been using for many years to maintain our Linux computers.

Script update
#!/usr/bin/perl

##############################################################################
# This script is run daily to update OS X computers running the standard
# configuration.
##############################################################################

use strict;

BEGIN {
$PkgUpdate::noyaml = 0;
unless (eval "use YAML; 1") {
warn "YAML not found, falling back to simple config.\n";
$PkgUpdate::noyaml = 1;
}
}

use File::Temp qw/tempdir/;

my $configfile = "/etc/localconfig";
my $window = 4*60*60; # 4 hours

# Establish lock on update system:
if ( -f "/var/run/update" ) {
chomp( my $OLDPID = `cat /var/run/update` );
chomp( my $PROCESS = `ps -p $OLDPID |wc -l` );
if ( $PROCESS > 1 ) {
`echo "Update system is locked!" | /usr/bin/mail -s "Message from Update" root`;
exit;
} else {
print "Removing stale lock file.\n";
unlink ("/var/run/update");
}
}
`echo $$ > /var/run/update`;


# Unless instant gratification is requested, wait for a random
# time, from 0 to 4 hours, to avoid hitting the server too hard
# all at once:
unless ( $ARGV[0] eq "now" ) {
my $wait = int(rand($window));
print "Sleeping for $wait seconds...\n";
sleep($wait);
}

# Load local configuration:
my $config;
my %simpleconfig;
unless ($PkgUpdate::noyaml) {
$config = LoadFile("$configfile.yml") or die "Cannot load config file $configfile.yml: $!\n";
} else {
%simpleconfig = simpleconfig($configfile);
$config = \%simpleconfig;
}

# Resync /common/manager:
if ( $config->{CACHE_COMMON} eq "yes" or
$config->{CACHE_COMMON_MANAGER} = "yes" ) {
print "Resynchronizing /common/manager...\n";
`chflags -R nouchg /common/manager`;
print `rsync -aqz --delete $config->{UPDATEMASTER}::update-$config->{UPDATEVERSION}/common/manager/ /common/manager/`;
`chflags -R uchg /common/manager`;
}

# Run pre-update script:
if ( -x "/common/manager/update.prescript" ) {
print "Running pre-update script...\n";
print `/common/manager/update.prescript`;
}

# Resync config files with master copies:
print "Resynchronizing config files...\n";
chomp( my $NOW = `date +%y%m%d%H%M%S` );
my $EXCL = "";
my $INCL = "";
-f "/etc/CACHE_CONFIG.exclude" && ($EXCL="--exclude-from=/etc/CACHE_CONFIG.exclude");
-f "/etc/CACHE_CONFIG.include" && ($INCL="--include-from=/etc/CACHE_CONFIG.include");
-d "/etc/oldconfigs" || mkdir("/etc/oldconfigs");
mkdir ("/etc/oldconfigs/$NOW");
print `rsync -aqz -b --backup-dir="/etc/oldconfigs/$NOW" -u $EXCL $INCL $config->{UPDATEMASTER}::update-$config->{UPDATEVERSION}/config/ /`;
# If oldconfig directory is empty, remove it:
rmdir ("/etc/oldconfigs/$NOW");

# Resync with master images, if requested:
if ( $config->{CACHE_COMMON} eq "yes" ) {
print "Resynchronizing /common...\n";
`chflags -R nouchg /common`;
print `rsync -aqz --delete $config->{UPDATEMASTER}::update-$config->{UPDATEVERSION}/common/ /common/`;
`chflags -R uchg /common`;
}
if ( $config->{CACHE_LOCAL} eq "yes" ) {
print "Resynchronizing /local...\n";
my $excl = "";
my $incl = "";
-f "/local/etc/CACHE_LOCAL.exclude" && ($excl = "--exclude-from=/local/etc/CACHE_LOCAL.exclude");
-f "/local/etc/CACHE_LOCAL.include" && ($incl = "--include-from=/local/etc/CACHE_LOCAL.include");
print `rsync -aqz -u $excl $incl $config->{UPDATEMASTER}::update-$config->{UPDATEVERSION}/local/ /local/`;
}

# Do Apple softwareupdate:
print "Doing Apple Softwareupdate...\n";
print `softwareupdate --install --all`;

# Do local pkg updates:
print "Doing Local Package Updates...\n";
print `/common/manager/pkgupdate /common/manager/update.pkglist`;

# Run post-update script:
if ( -x "/common/manager/update.postscript" ) {
print "Running post-update script...\n";
print `/common/manager/update.postscript`;
}

# Print time stamp and unlock update system:
my $now = localtime();
print "Update completed at $now\n";
unlink ("/var/run/update");

sub simpleconfig {
my $file = shift;

my %config = ();
open (FILE,"<$file") or die "Cannot open simpleconfig file $file: $!\n";
while (<FILE>) {
/^\#/ && next;
/^([^:]+)?\s*:\s*(.*)?\s*$/;
$config{$1} = $2;
}
close (FILE);
return %config;
}
The local directory trees mentioned above are /common and /local. There are two of them for a couple of reasons: one historical and probably no longer valid, and another still relevant. The historical reason is that Once Upon A Time we couldn't count on the local machine having enough disk space to accommodate all of the files we wanted to put into local directory trees, so we split the original "/common" and put some of the files (the ones that would be accessed most often) into "/local". We could then NFS-mount /common on machines with small disks.

This arrangement proved useful for another reason, even after disk sizes far outstripped our needs. It allowed us to distribute management responsibilities, where appropriate. For example, a user's search path now looks something like this:
/local/bin:/common/bin:/usr/bin:/bin
If a computer has a local administrator, he or she can effectively replace any software in /common/bin by installing a file with the same name in /local/bin. More about this later.

The "update" script begins by checking to see if any older update processes are still running. In the past, we've sometimes seen network problems that cause update processes to pile up on a machine. This locking step was implemented to avoid that problem. If a valid lock is found, the process e-mails the local "root" account (which is just an alias in /etc/aliases, pointing to a central e-mail address for receiving such reports) and exits. Otherwise, the update script establishes its own lock and proceeds.

At this point, the script normally waits for a random time between 0 and 4 hours in order to spread out the load on the servers that supply update information. This wait can be skipped by supplying a command-line flag ("now").

The update script itself, and other associated scripts, live in the directory /common/manager. Before updating anything else, the update script first resynchronizes this directory with a master copy living on a central server. This ensures that changes to any of the update configuration files or subsidiary scripts will be made before the rest of the update procedure is executed. (Note that any changes to the update script itself won't take effect until after two update cycles. This could be avoided by re-exec'ing /common/manager/update with an appropriate flag after /common/manager has been updated, but we've never found that necessary.)

Between updates, all flags in the /common filesystem are kept in an immutable state. This prevents accidental (or trivial malicious) modification of these files, and it reinforces the idea that /common should always be an exact copy of a master image stored on a central server. Any local changes should be made in /local instead. Before synchronizing /common/master (or, later in the script, /common itself) the immutable flag is temporarily lifted, and then restored after the synchronization is complete. Note that under OS X we're using the "uchg" flag instead of "schg", since we need to turn immutablity on and off.

The actual syncronization is done with rsync. For /common/manager, the script constructs an rsync source address of the form:
$UPDATEMASTER::update-$UPDATEVERSION/common/manager/
and synchronizes that with the destination /common/master/ . The parameters UPDATEMASTER and UPDATEVERSION are drawn from a local configuration file, either /etc/localconfig or /etc/localconfig.yml.

Local configuration flags, CACHE_COMMON and CACHE_COMMON_MASTER allow a local administrator to turn off synchronization of all of /common or only /common/master.

After /common/manager is updated, the script looks for an executable file called /common/manager/update.prescript. If this is found, it is executed. The update.prescript file can contain any commands deemed necessary to be done before the rest of the update process. Later on, after most of the update process is completed, the update script will also look for an update.postscript script. As a matter of local convention, we make additions to these scripts cumulative. In other words, whenever we add a task to one of the pre- or post-scripts, we add code to check to see if the task has already been done, and skip it if so. This allows us to bring up-to-date any hosts that have been offline for a while and missed some updates, while avoiding re-doing tasks on every machine.

After the pre-script has been run, the update script next resynchronizes selected local configuration files with master copies on a central server. This is another place where a local administrator can step in and control the update process, by selectively excluding some configuration files. The resynchronization is again done with rsync, which this time uses a source of the form
$UPDATEMASTER::update-$UPDATEVERSION/config/
This is synchronized with the destination "/", but this time the synchronization isn't exact:
  • Files present on the local machine, but not present in the master copy, are ignored.
  • Backup copies of any files changed during the resynchronization are saved into a date-stamped directory under /etc/oldconfigs
  • The rsync process looks for include/exclude lists in the local files /etc/CACHE_CONFIG.include and /etc/CACHE_CONFIG.exclude
The config file tree on the server might look something like this:
|-- Library
| |-- LaunchDaemons
| | `-- org.zebedee.zebedee.plist
| `-- Preferences
| |-- SystemConfiguration
| | `-- com.apple.PowerManagement.plist
| `-- com.apple.sharing.firewall.plist
`-- private
`-- etc
|-- bashrc
|-- csh.cshrc
|-- hostconfig
`-- profile


Next, the rest of the /common tree is synchronized with the master copy. The synchronization is exact in this case, with any extra local files being deleted. As before, immutable flags are cleared before the resynchronization and re-instated afterward. The local configuration variable CACHE_COMMON allows the local administrator to selectively skip this part of the update process.

After /common is resynchronized, the update script resynchronizes /local. This is a looser resynchronization, without the --delete flag, so that the local administrator can add extra things to the /local tree that aren't present in the master copy. The local administrator can also selectively exclude parts of the tree from synchronization by using the /etc/CACHE_LOCAL.exclude and CACHE_LOCAL.include files.

The update script next does an Apple softwareupdate, automatically installing all available appropriate updates. Once that's finished, it uses our pkgupdate script to update any locally-installed third-party packages. The list of tasks for pkgupdate is stored in /common/manager/update.pkglist .

Finally, the catchall post-update script (if any) is run and the update lock is cleared.

Friday, July 07, 2006

A Simple Package-Management Script

This is the third in a series of posts about a simple nightly update system for OS X.

In previous posts I've shown scripts to automate the installation of software under OS X, at least for software packaged in several of the common Mac formats (app bundles and pkg & mpkg bundles, possibly packed into a dmg image). In this post I'll talk about a simple package-management script that I'll use to keep packages up-to date, and to install new packages (and eventually, uninstall unwanted packages) in an automatic way every day.

The script checks a central repository (located on an anonymous ftp site) for a list of available packages, then looks at a master list of packages that should be installed on all standard computers. If any of the packages on the master list aren't installed locally, or if a newer version is present in the repository, those packages are fetched from the repository and installed. Updated packages can thus be distributed to all standard computers by simply adding updated packages to the repository, and new software can be added by taking the additional step of adding the new packages to the master list. Currently, I haven't implemented any mechanism for removing packages. This system is analogous to one we've been using successfully for many years on our Linux computers.

Note that this system is only intended to supplement the services already provided by Apple's own softwareupdate command. It's intended to install and maintain non-Apple software that won't be updated by softwareupdate.

I call the script "pkgupdate", and it looks like this:
Script pkgupdate:
#!/usr/bin/perl

use strict;

BEGIN {
$PkgUpdate::noyaml = 0;
unless (eval "use YAML; 1") {
warn "YAML not found, falling back to simple config.\n";
$PkgUpdate::noyaml = 1;
}
}

use File::Temp qw/tempdir/;

my $configfile = "/etc/localconfig";
my @pkgtypes = ('app.tar.gz',
'mpkg.tar.gz',
'pkg.tar.gz',
'dmg');

# Get list of actions:
my $pkglist = shift;
my $keep = shift;

# Load local configuration:
my $config;
my %simpleconfig;
unless ($PkgUpdate::noyaml) {
$config = LoadFile("$configfile.yml") or die "Cannot load config file $configfile.yml: $!\n";
} else {
%simpleconfig = simpleconfig($configfile);
$config = \%simpleconfig;
}

# Specify package URL:
my $pkgurl = "ftp://$config->{UPDATEMASTER}/$config->{UPDATEVERSION}/PKGS";

# Create temp directory:
my $cleanup = 1;
$keep && ($cleanup = 0);
my $tmpdir = tempdir( CLEANUP => $cleanup );
print "Using tmpdir $tmpdir.\n";

# Get list of available packages:
print "Fetching list of available packages... ";
`cd $tmpdir && curl -s -O $pkgurl/.pkglist`;
my %pkglist = ();
open (PKGLIST, "<$tmpdir/.pkglist") or die "Cannot open $tmpdir/.pkglist: $!\n";
while (<PKGLIST>) {
chomp;
/^(\w{32})\s+(.*)$/;
$pkglist{$2}{sum} = $1;
}
close (PKGLIST);
print "Done.\n";

# Create local package database, if it doesn't already exist:
unless ( -f "/etc/pkglist.sqlite3" ) {
print "Local package database /etc/pkglist.sqlite3 not found. Creating.\n";
print `sqlite3 /etc/pkglist.sqlite3 "create table package (sum text, file text)"`;
$? && die "Error creating /etc/pkglist.sqlite3\n";
}

# Get list of already-installed packages
my %pkglocal = ();
open (PKGLIST,"sqlite3 /etc/pkglist.sqlite3 \"select * from package\" |" ) or die "Cannot get list of locally-installed packages: $!\n";
while (<PKGLIST>) {
chomp( my ($sum,$file) = split(/\s*\|\s*/) );
$pkglocal{$file}{sum} = $sum;
}
close (PKGLIST);

# Process packages to install/update:
open (PKGLIST, "<$pkglist") or die "Cannot open $pkglist: $!\n";
PACKAGE: while (<PKGLIST>) {
chomp;

# Skip comments and blanks:
/^(\#.*|\s*)$/ && next PACKAGE;

my ($action,$package) = split(/\s+/);
# Convert from glob syntax to regexp:
$package =~ s/\*/\.\*/g;
$package =~ s/\?/\./g;
# Find any matching packages:
my @matches = grep (/$package/, reverse sort keys %pkglist);
unless ( @matches ) {
warn "No matching package found for $package. Skipping.\n";
next PACKAGE;
}
my $match = $matches[0];
# If multiple matches are found, choose one of them:
if ( @matches > 1 ) {
TYPE: for my $t (@pkgtypes) {
for my $m (@matches) {
if ( $m =~ /.*$t$/ ) {
$match = $m;
last TYPE;
}
}
}
}

# Find package type:
my $type = $match;
$type =~ s/.tar.gz$//;
$type =~ /^.*\.([^\.]+)$/;
$type = $1;

# Check to see if we already have this package:
unless ( $pkglocal{$match}{sum} eq $pkglist{$match}{sum} ) {

print "Executing action $action for package $match of type $type...\n";

for ($action) {
/install/ && do {
# Escape any spaces:
my $escmatch = $match;
$escmatch =~ s/(\s)/\\$1/g;
# Invoke the appropriate installer for this type:
`cd $tmpdir && curl -s -O $pkgurl/$escmatch`;
chomp( my $result = `md5 -r $tmpdir/$escmatch` );
$result =~ /^(\w{32}).*$/;
my $checksum = $1;
unless ( $checksum == $pkglist{$match}{sum} ) {
print STDERR "Error: Checksum mismatch for package $match. Skipping.\n";
next PACKAGE;
}
if ( $escmatch =~ /^(.*)\.tar\.gz$/ ) {
`cd $tmpdir && tar xzf $escmatch && rm $escmatch`;
$escmatch = $1;
}
unless ( print `cd $tmpdir && /common/manager/$type-install $escmatch`) {
print STDERR "Error: $!, $?\n";
next PACKAGE;
}
if ( $? ) {
print STDERR "Error installing package $match. Skipping local package list update.\n";
next PACKAGE;
}
print "Updating local package database.\n";
print `sqlite3 /etc/pkglist.sqlite3 "insert into package (file,sum) values ('$match','$pkglist{$match}{sum}')"`;
if ( $? ) {
print STDERR "Error updating local package database.\n";
next PACKAGE;
}
};
}
}
}
close (PKGLIST);

sub simpleconfig {
my $file = shift;

my %config = ();
open (FILE,"<$file") or die "Cannot open simpleconfig file $file: $!\n";
while (<FILE>) {
/^\#/ && next;
/^([^:]+)?\s*:\s*(.*)?\s*$/;
$config{$1} = $2;
}
close (FILE);
return %config;
}
The script draws local configuration information from either a YAML file (if this is available) or a simple file with the format "variable: value" if not. Only two parameters are used from the local configuration: UPDATEMASTER and UPDATEVERSION. From these, an ftp url of the form
ftp://$UPDATEMASTER/$UPDATEVERSION/PKGS
is constructed. This is the location of the package repository.

The repository can contain software packaged as either dmg files or compressed tar archives of application or package bundles. The repository must also contain the file ".pkglist", which is a list of available files and their checksums. This list can be maintained on the server with a command like the following:
(find . -type f -print0 | sed -e 's/\.\///g'| xargs --null md5sum) > .pkglist
which can either be run by hand after dropping a new package into the repository, or run periodically through a cron job. Note that the "-print0" flag on "find" and the "--null" flag on xargs are necessary because many of the file names will contain spaces. I also pipe the "find" output through a sed command to strip off the leading "./" that would otherwise appear in the file names.

The resulting .pkglist file looks like this:
bd2ab919477b545bdc51209ae9ade105  Firefox 1.5.0.1.dmg
0c1920da27ead93b41958afa1c80f2fd Fugu1.2.0.dmg
504d1e037a639753e307e3d48e3f1f01 II2.dmg
72091614b4656fbdb0d5bc2f74abfe5a OracleCalendar.dmg
350a369b3ec955537822b4387b17b101 RDC103EN.dmg
5c38e7bbb389d129430c3af169745314 Thunderbird 1.5.dmg
f771d754c01023856444628514865e2e mozilla-mac-1.7.12.dmg
49369f4e72fe334c23d52489fc61110f OSXvnc1.71.dmg
The checksums will be used on the local computer to determine whether a given package/version has already been installed. The pkgupdate script maintains a local sqlite database of the packages it installs. After pkgupdate fetches the .pkglist file, compares the checksums of the available packages with the checksums of the already-installed packages and uses this information when deciding whether a package needs to be fetched from the repository and installed. I've used checksums for this to avoid having to try to figure out what version of a piece of software is packed into a given file. Filenames are unreliable for this purpose, and it would be complicated to unpack each package and try to find out what version number (or numbers, in the case of multi-packages!) was associated with it. Instead, I just assume that if two packages have the same checksum, they are the same version.

The pkgupdate script takes one argument: the name of the file containing the master list of packages to be installed on all computers. That file looks like this:
install OSXvnc*
install Firefox*
install Thunderbird*
install mozilla-mac*
install OracleCalendar*
install Fugu*
install II2*
install magicolor2430DL
install RDC103EN
with each line consisting of an action followed by a (possibly wild-carded) package name. Currently, the only available action is "install", although I'd like to add "remove" eventually, since we support this under the analogous system used on our Linux computers. The package names should match names in the .pkglist file.

When pkgupdate determines that it needs to install a package, it fetches the appropriate file from the ftp repository using curl, then invokes an external installer script (dmg-install, app-install or pkg-install), chosen based on the name of the downloaded file. (Compressed tar archives are first unpacked.)

If the installation succeeds, pkgupdate updates the sqlite database containing information about currently-installed packages. This lives in /etc/pkglist.sqlite3 . Currently, the database only contains filenames and corresponding checksums.

In the finaly posting in this series, I'll show how pkgupdate can be used as a component of a general-purpose nightly update system for OS X.

Automating pkg and app Bundle Installs

This is the second in a series of posts about a simple nightly update system for OS X

In the previous post I showed a script that mounted a dmg image, looked for packages and application bundles inside it, and called other scripts to install these packages and application bundles. In this post, I'll show the scripts I call "pkg-install" and "app-install", which do do actual work of installing software.

First, app-install, which installs application bundles by simply copying them into the /Applications directory.

Script app-install
#!/usr/bin/perl

use strict;
use File::Basename;

my $a = shift;

# Check to see if app of this version is already installed, and
# install if not.
my $appdir = basename($a);
if ( -d "/Applications/$appdir" ) {
chomp ( my $vnew =
`cat $a/Contents/Info.plist|grep -A1 CFBundleShortVersionString|tail -1` );
$vnew =~ s/\<\/*string\>//g;
$vnew =~ s/(^\s+|\s+$)//g;
chomp ( my $vold =
`cat /Applications/$appdir/Contents/Info.plist|grep -A1 CFBundleShortVersionString|tail -1` );
$vold =~ s/\<\/*string\>//g;
$vold =~ s/(^\s+|\s+$)//g;
if ( $vold == $vnew ) {
print "Application bundle of the same version ($vnew)";
print "is already installed.\n";
print "Skipping installation.\n";
exit;
}
}
print "Installing $a in /Applications...\n";
print `cp -r -p $a /Applications/`;
$? && die "Copy failed for application bundle $a\n";

The script takes a single argument, the name of the app directory, and starts by checking to see if an application of the same version is already installed. It does this by first looking for an app directory of the same name under /Applications and then, if it finds one, looking at the "CFBundleShortVersionString" key in the Info.plist files of the two application bundles. If the key is the same in both cases, the script assumes that the applications are identical and skips the installation. Unfortunately, there's no reliable way to tell if an already-installed version is newer than the candidate for installation, since version "numbers" are unique to each application and often contain more than just digits. Could a program reliably decide whether version 1.3-beta2 is newer than 1.3-development-1.7? The app-install script doesn't address this, and just assumes that if it's told to install a different version (older or newer), that's that the person invoking app-install really wants to do.

For pkg bundles (and multi-packages) I have a second script, called "pkg-install".
Script pkg-install
#!/usr/bin/perl

use strict;
use File::Basename;

my $p = shift;

# Check to see if pkg of this version is already installed, and
# install if not.
my $pkgdir = basename($p);
if ( -d "/Library/Receipts/$pkgdir" ) {
chomp ( my $vnew =
`cat $p/Contents/Info.plist|grep -A1 CFBundleShortVersionString|tail -1` );
$vnew =~ s/\<\/*string\>//g;
$vnew =~ s/(^\s+|\s+$)//g;
chomp ( my $vold =
`cat /Library/Receipts/$pkgdir/Contents/Info.plist|grep -A1 CFBundleShortVersionString|tail -1` );
$vold =~ s/\<\/*string\>//g;
$vold =~ s/(^\s+|\s+$)//g;
if ( $vold == $vnew ) {
print "Package of the same version ($vnew) is already installed.\n";
print "Skipping installation.\n";
exit;
}
}
print "Installing $p...\n";
print `installer -pkg $p -target /`;
$? && die "installer failed for package $p\n";

Again, the script first checks for an already-installed package of the same version. In this case, the script looks in the /Library/Receipts directory, where installer places metadata about packages after it installs them. If a package of the same version isn't already installed, the script then invokes installer to install the candidate package.

Automating Installs from DMG Images

This post is the first in a series documenting the development of a simple nightly update system for OS X. The system is intended to be a quick-and-dirty 80% solution for keeping standard OS X computers in our department up to date. We've been doing something similar for Linux for many years, and the OS X solution is an outgrowth of this.

I'll begin with the following script for installing software from dmg images, which I'll describe below:

Script dmg-install:
#!/usr/bin/perl

use strict;
use File::Basename;

my $DMG = shift;
$DMG =~ s/(\s)/\\$1/g;

# Attach and mount image:
print "Mounting image $DMG... ";
my $result = `yes| hdiutil attach $DMG | tail -1`;

# Get mounted volume information:
$result =~ /^.*\/dev\/disk.*(\/Volumes\/.*)$/;
my $mountpoint = $1;
$mountpoint =~ s/(\s)/\\$1/g;
print "$DMG is mounted on $mountpoint.\n";

# Find packages and application bundles:
my @apps = glob("$mountpoint/*.app");
my @pkgs = glob("$mountpoint/*.pkg");
push (@pkgs,glob("$mountpoint/*.mpkg"));

unless ( @apps or @pkgs ) {
my $list = `ls -al $mountpoint/`;
die "No application bundles or packages found in disk image.\nHere's what I see:\n$list";
}

for my $a (@apps) {
$a =~ s/(\s)/\\$1/g;
print "Installing application bundle $a...\n";
print `/common/manager/app-install "$a"`;
$? && die "Failed to install application bundle $a\n";
}

for my $p (@pkgs) {
$p =~ s/(\s)/\\$1/g;
print "Installing package $p...\n";
print `/common/manager/pkg-install "$p"`;
$? && die "Failed to install package $p\n";
}

print "Unmounting image $DMG from mountpoint $mountpoint...\n";
print `hdiutil detach $mountpoint`;


The dmg-install script takes the name of a dmg file as its only argument. The script uses hdiutil to mount the image, answering "y" to any questions asked during the process. Hdiutil returns information about the location of the mount point, and the script uses this to look there for any application bundles, pkg bundles or multi-packages. The list of application bundles is fed to a separate installation script called "app-install" (which I'll describe in a later post) and the list of pkg and mpkg bundles is fed to a script called "pkg-install" (also to be described later). After all bundles are installed, dmg-install unmounts the dmg image and exits.

Thursday, July 06, 2006

More on Disk Images, Resource Forks, Licenses

In a previous post I talked about disk images (dmg files) as a common mechanism for distributing software for OS X. I mentioned that dmg files can contain license agreements that must be accepted before the disk image can be mounted.

To understand the mechanism used for this, I'll need to talk about resource and data forks. In earlier versions of Apple's operating systems, each file actually had two parts, called a resource fork and a data fork. The file's data and metadata are split (in a sometimes complicated way) between the two forks. Often, the data fork is similar to what we'd normally think of as the file itself, and the resource fork carries metadata, in a binary format, about the file: an icon, a description, the name of an associated application, etc.. The resource fork is normally invisible to the user, and the filesystem takes care of keeping data and resource forks "connected". (When the file is moved to a new directory, for example, both forks are simultaneously moved.)

Resource forks are rarely used under OS X, but they're still supported by its HFS+ filesystem. You can get to a file's resource fork by appending "/rsrc" onto the file name. For example:
# ls -al /etc/bashrc
-rw-r--r-- 1 root wheel 250 Jun 15 10:48 /etc/bashrc
# ls -al /etc/bashrc/rsrc
-rw-r--r-- 1 root wheel 0 Jun 15 10:48 /etc/bashrc/rsrc
Weird, eh?

One problem with resource forks is that they can't easily be transported across the network (at least not to non-Apple servers). For example, If you ftp a file from an OS X computer to a Linux computer, only the data fork will be transported. Similarly, if you download a file from a web server, you'll only get the data fork, even if the file originally had a resource fork.

One way around this is to "flatten" the file by packing both resource and data forks into a new data file in such a way that they can be unpacked again later. This is often done with dmg files. A flattened dmg file can be unflattened with htiutil ("hdiutil unflatten file.dmg").

Licenses are often added to the resource forks of dmg files before they are flattened. Each component of a resource fork has a type (such as 'TEXT') and a numerical identifier. Before mounting a disk image, hdiutil looks in the dmg file's resource fork for a TEXT section with identifier 5002. This section holds the license agreement associated with the dmg. The perl script BuildDMG can be used to create dmg files, and it is capable of attaching a license as part of the process. BuildDMB hdiutil to unflatten the dmg file, then it uses the Rez command from the Xcode tools to compile the resource fork from a source-code description (here's an example). The dmg file is then re-flattened and ready for distribution.

To automate the mounting of a license-containing dmg file, you can either do something like this:
yes | hdiutil attach file.dmg
which will just answer "y" to any questions asked by hdiutil (e.g., "Do you accept this license?"), or you can unflatten the dmg file, clear its resource fork and reflatten it before trying to mount the image. One way to do this would be:
touch junk.rsrc
hdiutil unflatten file.dmg
/Developer/Tools/Rez junk.rsrc -o file.dmg/rsrc
hdiutil flatten file.dmg

chflags, Immutable files, Securelevel, Single-User Mode

It's often useful to mark a file as "immutable", requiring even the superuser to take some additional action before the file can be modified. Under Linux's ext2/3 filesystem this is implemented through the "attributes" commands (chattr, lsattr), using the immutable attribute. Under Windows, the "attrib" command provides similar functionality by allowing you to mark files as read-only.

Under Mac OS X, files can be marked immutable by using the "chflags" command. Chflags provides a way to attach several extended attributes (or "flags") to files and directories. Available flags are (from the man page):
arch    set the archived flag (root only)
opaque set the opaque flag (owner or root only)
nodump set the nodump flag (owner or root only)
sappnd set the system append-only flag (root only)
schg set the system immutable flag (root only)
sunlnk set the system undeletable flag (root only)
uappnd set the user append-only flag (owner or root only)
uchg set the user immutable flag (owner or root only)
uunlnk set the user undeletable flag (owner or root only)
There are also several aliases, such as "archived" for "arch". Each of the flags can be unset by prepending "no" (e.g., "nouchg" is the opposite of "uchg"). Flags set by chflags can be viewed with "ls -lo".

Chflags actually provides two slightly different "immutable" flags: uchg and schg. There are two differences between these flags. First, only the superuser can set the schg flag, but the uchg flag can also be set by a file's owner. Second (and perhaps more important), even the superuser may be unable to un-set the schg flag, depending on the current securelevel of the system.

Mac OS X inherits the concept of securelevel from BSD. The idea is that the kernel contains a variable called "securelevel". At boot time, securelevel has a value of 0, but this can be changed later. Once the securelevel has been raised, it cannot be lowered (even by the superuser) without a reboot. When Mac OS X goes into multi-user mode, it sets the value of securelevel to 1. You can see this with the "sysctl" command:
sysctl kern.securelevel
Going back to the immutable flags, the schg flag differs from the uchg flag in that schg cannot be unset unless the operating system is in securelevel 0. This means that if you set the schg flag on a file, you won't be able to unset it without booting into single-user mode (in which securelevel is still 0). You can boot into single-user mode by holding down the "command" (funky apple octothorpe/clover symbol) and "s" keys while booting.

Thursday, June 29, 2006

Software, Packages, Installer, and Softwareupdate

OS X provides several command-line tools for managing software packages. Note that software can be distributed for OS X in many different ways, and that packages are only one option. I'll start with some of the other alternatives.

Single Binaries

If you only need to install a single executable file that depends on no other files, you can just drop it into some directory on your search path. The default search path is "/bin:/sbin:/usr/bin:/usr/sbin".

Application Bundles

For software that requires several files, Apple offers a nice method of "drag and drop" installation. Under OS X, applications that are intended for use under the graphical interface are installed in the /Applications directory. Under this directory, each application has its own subdirectory, called an "application bundle", named with a ".app" extension (e.g., MyApp.app). Viewed from the finder, each of these app directories appears as a single file, although the directories contents may still be viewed by clicking the "gear" icon on the finder toolbar and selecting "Show Package Contents". These app directories can simply be dragged from another location (a CD, for example) and dropped into the /Applications folder. That's all that's needed to install the application. For information on the structure of an app directory, see The Structure of a Modern Bundle. Here's a partial listing of the contents of the Fugu application bundle (I've omitted a large number of language-specific files):
Fugu.app
`-- Contents
|-- Info.plist
|-- MacOS
| `-- Fugu
|-- PkgInfo
`-- Resources
|-- COPYRIGHT.txt
|-- ODBEditors.plist
|-- bookmark.png
|-- disconnect.png
|-- download.png
|-- downtriangle.png
|-- edit.png
|-- favorites.png
|-- favoritesprefs.png
|-- files.png
|-- fugu.icns
|-- generalprefs.png
|-- goto.png
|-- history.png
|-- home.png
|-- info.png
|-- knownhosts.png
|-- newfolder.png
|-- preview.png
|-- reload.png
|-- remotehistory.png
|-- remotehome.png
|-- remotetrash.png
|-- righttriangle.png
|-- symlink.png
|-- transfers.png
|-- trash.png
|-- uparrow.png
|-- upload.png
`-- zeroconf.png
At the top level is a "Contents" directory containing a plist file describing the application (specifying which icon to display, for example). Under this is a "MacOS" directory, containing the application executable itself ("Fugu") and a "Resources" directory containing files used by the application (such as icons, in this case).


Installer Packages

If an application requires interaction with the user during installation (to ask for default settings, for example), if tasks need to be done to prepare the system before installing the application, or if the software creator just wants to make the installation process a little prettier, the application may be delivered in the form of a package. Apple's native package format consists of a directory, named with a ".pkg" extension, and containing all of the files necessary to install the application. Like app directories, pkg directories are shown by the finder as a single file. For more information on the structure of a pkg directory, see The Anatomy of a Package.

Packages can be installed from the command line using the installer command. (Double-clicking on a pkg directory in the graphical interface will automatically launch installer.) More information about installer here.

Disk Images

Since it's usually inconvenient to copy a whole directory tree over the network, the most common way of distributing software is in the form of a single disk image (dmg) file. Each such dmg file contains an HFS filesystem containing a pkg or app directory tree. Dmg files can even by "internet-enabled" (by setting a flag in the file, using the hdiutil command), causing Safari to automatically mount, unpack and delete them after they're downloaded from a web server. Dmg files can also contain license agreements that will be displayed (and must be agreed to) before the image can be mounted.

Disk images can be mounted using the "hdiutil attach" command, like this:
hdiutil attach Firefox-1.5.0.1.dmg
In the case of Firefox, the dmg contains a license agreement, which hdiutil displays. The user is prompted to accept the license, and then hdiutil proceeds to create a device associated with the disk image (e.g., /dev/disk1) and mount it under "/Volumes" (in this case "/Volumes/Firefox").

Softwareupdate

Finally, Apple provides a tool for updating software, called "softwareupdate". Softwareupdate is a command-line tool that can be used to look for available Apple software updates and/or install them. The command:
softwareupdate --list
will produce a list of available updates. Here's an example:
Software Update found the following new or updated software:
* QuickTime-7.1.2
QuickTime (7.1.2), 52650K [recommended] [restart]
* iTunesX-6.0.5
iTunes (6.0.5), 19340K [recommended]
The command:
softwareupdate --install --all
will find all available updates and install them. If one or more updates require a reboot, you'll be asked to restart the computer after the updates are installed. With the command
softwareupdate --schedule on
you can cause softwareupdate to automatically check for updated software. The check is done once per week, and is implemented by changing the current user's ~/Library/Preferences/com.apple.scheduler.plist file. This seems to imply that the checks will only occur when this user is logged in at the graphical console, although I haven't been able to find documentation verifying this.

For more information about software distribution, see this article.