#!/usr/bin/perl
#
# volnuke -- Delete a volume, tracking down what servers it's on.
#
# A smart and dangerous vos remove, but one that prompts you to be sure you're
# doing what you intend.  Deletes a volume without having to know the volume's
# location beforehand, including tracking down and removing all the
# replication points.  If the volume is replicated, it also checks to be sure
# that none of the replicas have been accessed.
#
# "These had really struck terror into the hearts of everyone who had
# encountered them -- in most cases, however, the terror was extremely
# short-lived, as was the person experiencing the terror."
#                       -- Douglas Adams, _Life, the Universe, and Everything_
#
# Written by Russ Allbery <rra@stanford.edu>
# Copyright 2002, 2003, 2004, 2010, 2011, 2013
#     The Board of Trustees of the Leland Stanford Junior University
#
# Updated by Adam H. Lewenberg <adamhl@stanford.edu> in 2026
#
# This program is free software; you may redistribute it and/or modify it
# under the same terms as Perl itself.

## no critic (ProhibitParensWithBuiltins, RequireCarping)
## no critic (RequireNoMatchVarsWithUseEnglish, ProhibitDoubleSigils)

##############################################################################
# Modules and declarations
##############################################################################

use 5.24.0;
use strict;

# Make 'uninitialized' warnings fatal: a stray undef in a printed size
# or hostname could yield a confirmation prompt that looks reasonable
# but describes the wrong thing.  Other categories stay non-fatal to
# avoid tripping on benign third-party module warnings.
use warnings;
use warnings FATAL => 'uninitialized';

use English;
use Getopt::Long::Descriptive;
use IPC::Run qw/run/;
use List::Util qw(any);
use POSIX qw(strftime);

##############################################################################
# Site configuration
##############################################################################

our $VERSION = '3.0 (2026-07-08)';

# Dry-run flag.  Set from the --dry-run / --just-print / -n command
# line option below.  Declared here so subs defined earlier in this file
# (e.g. volume_remove) can see it under `use strict`.
my $JUSTPRINT;

# The full path to fs and vos.  These are declared with 'our' so that
# /etc/afs-admin-tools/config can override them via a bare assignment
# like "$VOS = '/usr/local/sbin/vos';" -- the require below evaluates
# in the main package, and 'our' aliases these names to $main::FS and
# $main::VOS.
our $FS  = '/usr/bin/fs';
our $VOS = '/usr/bin/vos';

# The AFS cell to operate in.  Also declared with 'our' so that the
# config file can override it via a bare assignment.
our $CELL = 'ir.stanford.edu';

# Set to 1 to show progress messages.
my $VERBOSE = 0;

# Default configuration file path.  May be overridden by --config on
# the command line; the load itself is deferred until after option
# parsing (see below) so that override can take effect.
my $CONFIG_FILE = '/etc/afs-admin-tools/config';

##############################################################################
# Utility routines
##############################################################################

# Use: pass in an array, returns ($stdout, $stderr, $exit_value)
sub run_command {
    my (@command) = @_;

    # (From Russ)
    my ($out, $err);
    IPC::Run::run(\@command, q{>}, \$out, q{2>}, \$err);

    ## no critic (ProhibitMagicNumbers)
    return ($out, $err, $CHILD_ERROR >> 8);
}

sub exit_with_error {
    my ($msg) = @_;

    print "error: $msg\n";
    exit 1;
}

# Given a filesystem path, prepend "./" if it is not already absolute.
# The AFS fs command rejects (or misinterprets) bare relative paths, so
# a path like "foo" must be passed as "./foo".  Absolute paths are
# returned unchanged.
sub normalize_path {
    my ($path) = @_;

    if ($path !~ m{^/}xsm) {
        $path = q{./} . $path;
    }
    return $path;
}

# Return 1 if the given string is a valid AFS volume name, 0 otherwise.
# AFS volume names may contain letters, digits, underscores, dots, and
# hyphens, and must be non-empty.
sub volume_name_valid {
    my ($name) = @_;

    if (!defined $name || $name eq q{}) {
        return 0;
    }
    if ($name !~ /\A[\w.-]+\z/xsm) {
        return 0;
    }
    return 1;
}

sub progress {
    my ($msg, $prefix) = @_;

    if ($VERBOSE) {
        # Split into individual lines.
        my @lines = split(/\n/xsm, $msg);

        my $header;

        if ($prefix) {
            $header = "[progress/$prefix]";
        } else {
            $header = '[progress]';
        }

        for my $line (@lines) {
            print "$header $line\n";
        }
    }
    return;
}

##############################################################################
# AFS information
##############################################################################

# Given a mount point, get the volume name of the volume mounted there.
sub mount_to_name {
    my ($path) = @_;

    # Reject backslashes and single quotes.  IPC::Run passes argv
    # elements without shell interpretation, so this is not defense
    # against shell injection.  Rather, a single quote embedded in
    # the path would confuse the "is a mount point for volume '#...'"
    # regex parser below, and a backslash has no legitimate use in an
    # AFS mount point on the operator command line.
    if ($path =~ /[\\\']/xsm) {
        exit_with_error("invalid character in $path");
    }

    $path = normalize_path($path);

    my @cmd = ($FS, 'lsmount', '-dir', $path);
    my ($stdout, $stderr, $rc) = run_command(@cmd);
    if ($rc != 0) {
        exit_with_error("fs lsmount $path failed (status $rc): $stderr");
    }

    # fs lsmount always emits a single line; chomp so the $ anchor
    # below matches regardless of trailing whitespace or \r\n.
    chomp $stdout;

    if ($stdout =~ /^\S+[ ]is[ ]a[ ]mount[ ]point[ ]for[ ]volume[ ]\'\#(\S+)\'$/xsm) {
        return $1;
    }
    exit_with_error("cannot determine volume from mount point $path");
}

# Given a volume name, determines various characteristics of the volume and
# returns them in a hash.  'volume' gets the volume name as passed in, 'size'
# gets the volume size in KB, 'rwserver' and 'rwpart' get the server and
# partition for the read-write volume, 'ro' gets a hash of server and
# partition values for the replicas, 'sites' gets a count of the number of
# sites the volume is replicated on, 'unreleased' gets a boolean value saying
# whether there are unreleased changes, and 'accesses' gets an array of
# access counts (one per replica, in sorted-by-server order matching 'ro').
sub volinfo {
    my ($volume) = @_;
    my %results;
    $results{volume} = $volume;

    # Ask vos for the RW attributes in key/value form.
    my %rw = _vos_examine_format($volume);

    if ($rw{type} && $rw{type} ne 'RW') {
        exit_with_error("$volume is $rw{type}, not RW");
    }

    # diskused is the volume size in KB.
    $results{size} = $rw{diskused};

    # Walk the site_server_N / site_partition_N / site_type_N triples.
    # site_server_N looks like: "IP<TAB>FQDN:port<TAB>UUID"; we want the
    # short hostname (the first dot-separated label of the FQDN).
    if (!defined $rw{site_count}) {
        exit_with_error("vos examine $volume: missing site_count");
    }
    my $site_count = $rw{site_count};

    # Sanity check: site_count must agree with the number of
    # site_type_N entries actually present in the output.  A mismatch
    # means the output was truncated or has some other integrity
    # problem; refuse to act on it rather than silently miss a site.
    my $actual_sites = grep { /\Asite_type_\d+\z/xsm } keys %rw;
    if ($actual_sites != $site_count) {
        exit_with_error(
            "vos examine $volume: site_count=$site_count but found "
            . "$actual_sites site_type_N entries"
        );
    }

    for my $i (0 .. $site_count - 1) {
        my $server_field = $rw{"site_server_$i"};
        my $partition    = $rw{"site_partition_$i"};
        my $type         = $rw{"site_type_$i"};
        if (!defined $server_field) {
            next;
        }

        my $short_host = _short_hostname_from_site_field($server_field);

        if ($type eq 'RW') {
            if ($results{rwserver}) {
                exit_with_error("saw two RW sites for $volume");
            }
            $results{rwserver} = $short_host;
            $results{rwpart}   = $partition;
        } elsif ($type eq 'RO') {
            $results{ro}{$short_host} = $partition;
            $results{sites}++;
        }
    }

    if (!($results{rwserver} && defined $results{size})) {
        exit_with_error("unable to parse vos examine $volume");
    }

    progress("$volume: RW on $results{rwserver} $results{rwpart}, "
             . "size $results{size} KB, "
             . ($results{sites} // 0) . ' replica site(s)',
             'volinfo');

    # If the volume is replicated, query each RO site individually with
    # vos listvol.  Doing so gives per-site updateDate (any stale replica
    # marks the volume as unreleased) and per-site dayUse (so the caller
    # can warn about replicas that have had recent accesses).
    if ($results{sites}) {
        my @counts;
        for my $ro_server (sort keys %{ $results{ro} }) {
            my $partition = $results{ro}{$ro_server};
            progress("querying vos listvol for $volume.readonly on "
                     . "$ro_server $partition",
                     'volinfo');
            my %ro = _vos_listvol_format(
                "$volume.readonly", $ro_server, $partition,
            );
            # Any stale replica marks the whole volume as unreleased.
            # Once set, this flag is deliberately never unset -- the
            # loop continues so we still collect dayUse for every site.
            # updateDate values from -format output look like
            # "1783547771\tWed Jul  8 14:56:11 2026", so extract the
            # leading epoch integer before comparing numerically.
            my ($rw_updated) = split /\t/xsm, $rw{updateDate} // q{}, 2;
            my ($ro_updated) = split /\t/xsm, $ro{updateDate} // q{}, 2;
            if ($rw_updated && $ro_updated && $rw_updated > $ro_updated) {
                $results{unreleased} = 1;
            }
            if (defined $ro{dayUse}) {
                push @counts, $ro{dayUse};
            }
        }
        if (@counts) {
            $results{accesses} = \@counts;
        }
    }

    return %results;
}

# Run "vos examine <name> -format" and return the parsed key/value pairs
# as a hash.  Lines produced by -format are of the form:
#     <key><TAB><value>
# but the "value" for some keys (like site_server_N) is itself a tab-
# separated list of fields, and some keys have a trailing "(Optional)"
# marker; we preserve the raw value verbatim after the FIRST tab so the
# caller sees exactly what vos printed.
sub _vos_examine_format {
    my ($name) = @_;
    my @cmd = ($VOS, 'examine', '-id', $name, '-cell', $CELL, '-format');
    my ($stdout, $stderr, $rc) = run_command(@cmd);
    if ($rc != 0) {
        exit_with_error("error running command '@cmd': $stderr");
    }

    my %kv;
    for my $line (split /\n/xsm, $stdout) {
        if ($line !~ /\S/xsm) {
            next;
        }
        my ($key, $value) = split /\t/xsm, $line, 2;
        if (!defined $key) {
            next;
        }
        $value //= q{};
        $kv{$key} = $value;
    }
    return %kv;
}

# Run "vos listvol -id <id> -server <server> -partition <partition>
# -format" for a single site and return the parsed key/value pairs as
# a hash.  The output starts with a "BEGIN_OF_ENTRY" marker line (which
# has no tab and is skipped) followed by the same <key><TAB><value>
# schema used by "vos examine -format".
sub _vos_listvol_format {
    my ($id, $server, $partition) = @_;
    my @cmd = ($VOS, 'listvol',
               '-id',        $id,
               '-server',    $server,
               '-partition', $partition,
               '-cell',      $CELL,
               '-format',
              );
    my ($stdout, $stderr, $rc) = run_command(@cmd);
    if ($rc != 0) {
        exit_with_error("error running command '@cmd': $stderr");
    }

    my %kv;
    for my $line (split /\n/xsm, $stdout) {
        if ($line !~ /\S/xsm) {
            next;
        }
        my ($key, $value) = split /\t/xsm, $line, 2;
        if (!defined $key) {
            next;
        }
        if (!defined $value) {
            next;
        }
        $kv{$key} = $value;
    }
    return %kv;
}

# The site_server_N field is "IP<TAB>FQDN:port<TAB>UUID".  Return the
# short hostname -- the first dot-separated label of the FQDN.
sub _short_hostname_from_site_field {
    my ($field) = @_;

    my (undef, $fqdn_and_port) = split /\t/xsm, $field, 3;

    if (!defined $fqdn_and_port || $fqdn_and_port eq q{}) {
        exit_with_error("cannot parse site_server field: '$field'");
    }

    my ($fqdn) = split /:/xsm, $fqdn_and_port, 2;
    if (!defined $fqdn || $fqdn eq q{}) {
        exit_with_error("cannot extract FQDN from site_server field: '$field'");
    }

    my ($short) = split /[.]/xsm, $fqdn, 2;
    return $short;
}

# Pretty-print a hashref returned by volinfo().  Renders every field
# in a stable, human-readable layout.  Fields that are absent (no
# replicas, no accesses, released) are simply omitted.  If accesses
# and replica sites are the same length, they are shown paired --
# each site with its access count -- since volinfo() collects them
# in matching sort order.
sub print_volume_info {
    my ($info) = @_;

    printf "  %-14s %s\n", 'volume:',  $info->{volume} // q{?};
    printf "  %-14s %s KB\n", 'size:',   $info->{size}   // q{?};
    printf "  %-14s %s %s\n", 'RW site:',
        $info->{rwserver} // q{?}, $info->{rwpart} // q{?};
    if ($info->{unreleased}) {
        printf "  %-14s %s\n", 'unreleased:', 'yes';
    }

    my @ro_servers = sort keys %{ $info->{ro} // {} };
    if (!@ro_servers) {
        return;
    }

    my $accesses = $info->{accesses};
    my $paired   = ($accesses && @{$accesses} == @ro_servers);

    if ($paired) {
        printf "  replica sites and accesses (past day):\n";
        for my $i (0 .. $#ro_servers) {
            my $srv  = $ro_servers[$i];
            my $part = $info->{ro}{$srv};
            printf "    %-20s %s  %6d accesses\n",
                $srv, $part, $accesses->[$i];
        }
    } else {
        printf "  replica sites (%d):\n", scalar @ro_servers;
        for my $srv (@ro_servers) {
            printf "    %s %s\n", $srv, $info->{ro}{$srv};
        }
        if ($accesses && @{$accesses}) {
            printf "  accesses (past day):\n";
            for my $count (@{$accesses}) {
                printf "    %6d\n", $count;
            }
        }
    }
    return;
}

# Remove a single volume site.  Prints the command line, then either
# runs it (via run_command) or skips it when the global $JUSTPRINT is
# set (dry-run).  Exits with an error if vos remove returns a non-zero
# exit code.
sub volume_remove {
    my ($server, $partition, $volume) = @_;
    my @cmd = ($VOS, 'remove',
               '-id',        $volume,
               '-server',    $server,
               '-partition', $partition,
               '-cell',      $CELL,
              );
    print "@cmd\n";

    if ($JUSTPRINT) {
        return;
    }

    my ($stdout, $stderr, $rc) = run_command(@cmd);
    if ($rc != 0) {
        exit_with_error("failed to remove $volume on $server $partition (status $rc): $stderr");
    }

    progress("removed $volume from $server $partition", 'vos-remove');
    return;
}

##############################################################################
# Implementation
##############################################################################

# Make sure that all output is sent immediately, since vos remove reports some
# things to stderr.
$OUTPUT_AUTOFLUSH = 1;

# Parse our options.
my $fullpath  = $PROGRAM_NAME;
$PROGRAM_NAME =~ s{.*/}{}xsm;

my ($opt, $usage) = describe_options(
    '%c %o volume-or-mountpoint',
    [ 'cell|C=s',                q{AFS cell to operate in (overrides $CELL)}         ],
    [ 'config=s',                'path to the configuration file'                    ],
    [ 'date|d',                  'prefix each list-file entry with the date'         ],
    [ 'file|f=s',                'append the removed volume name to this list file'  ],
    [ 'force|F',                 'skip the initial confirmation prompt'              ],
    [ 'mountpoint|m',            'treat the argument as a mount point, not a volume' ],
    [ 'dry-run|just-print|n',    q{print the vos remove commands; don't run them}    ],
    [ 'verbose|V',               'show progress messages'                            ],
    [ 'manual|M',                'show the full manual page and exit'                ],
    [ 'help|h',                  'show this usage summary and exit'                  ],
    [ 'version|v',               'print version and exit'                            ],
    { getopt_conf => [ 'bundling', 'no_ignore_case' ] },
);

if ($opt->help) {
    print $usage->text;
    exit 0;
}
if ($opt->manual) {
    print "Feeding myself to perldoc, please wait....\n";
    exec ('perldoc', '-t', $fullpath);
}
if ($opt->version) {
    print "volnuke $VERSION\n";
    exit 0;
}

my $prefix_date    = $opt->date;
my $list_file      = $opt->file;
my $force          = $opt->force;
my $use_mountpoint = $opt->mountpoint;
$JUSTPRINT         = $opt->dry_run;
$VERBOSE           = $opt->verbose;

# --config on the command line overrides the hardcoded default path.
# Load the config file now (deferred from top-of-file) so command-line
# --cell etc. can then override anything it sets.
if (defined $opt->config) {
    $CONFIG_FILE = $opt->config;
}
if (-f $CONFIG_FILE) {
    require $CONFIG_FILE;
}

# --cell on the command line overrides both the hardcoded default and
# any $CELL set by the config file.
if (defined $opt->cell) {
    $CELL = $opt->cell;
}

# Volume name or mount point is always the first argument.  Pull it off and
# figure out where this volume is.
if (@ARGV != 1) {
    print {*STDERR} $usage->text;
    exit 1;
}
my $volume;
my $mountpoint;
if ($use_mountpoint) {
    $mountpoint = shift;
    $mountpoint =~ s{/+$}{}xsm;
    $volume = mount_to_name ($mountpoint);
} else {
    $volume = shift;
}
if (!volume_name_valid($volume)) {
    exit_with_error("invalid volume name: $volume");
}
my %volume_info = volinfo $volume;
if ($VERBOSE) {
    print "\n[progress] volume info:\n";
    print_volume_info(\%volume_info);
}

# Report the details about the volume and get confirmation.
print "\n$volume on $volume_info{rwserver} $volume_info{rwpart} ($volume_info{size} KB)";
if ($volume_info{unreleased}) {
    print ' with unreleased changes';
}
print "\n";

for my $ro_server (sort keys %{ $volume_info{ro} }) {
    print "  replica on $ro_server $volume_info{ro}{$ro_server}\n";
}

if (!$force) {
    print "\nContinue (y/N)? ";
    ## no critic (ProhibitExplicitStdin)
    my $response = <STDIN> // q{};
    if ($response !~ /^y/ixsm) {
        exit;
    }
    print "\n";
}

if ($volume_info{accesses} && any { $_ != 0 } @{ $volume_info{accesses} }) {
    print "WARNING: Replica sites have accesses:\n\n";
    for my $count (@{ $volume_info{accesses} }) {
        printf "  %6d accesses in the past day\n", $count;
    }
    if ($force) {
        print "\nCowardly refusing to delete with --force in effect\n";
        exit 1;
    } else {
        print "\nAre you SURE you want to continue (y/N)? ";
        ## no critic (ProhibitExplicitStdin)
    my $response = <STDIN> // q{};
        if ($response !~ /^y/ixsm) {
            exit;
        }
        print "\n";
    }
}

# Install a SIGINT handler for the destructive phase only.  Before
# this point, Ctrl-C is safe -- nothing has been changed.  From here
# on, an interrupt may leave the volume with some sites removed and
# others still in place; the handler surfaces that so the operator
# knows to re-check state rather than assuming they interrupted in
# time.
$SIG{INT} = sub {
    print {*STDERR}
        "\nvolnuke: interrupted; $volume may be partially removed.\n"
        . "Re-run to finish, or check 'vos listvldb $volume' for state.\n";
    exit 130;
};

# Remove each replica site, then the RW site.
if ($volume_info{sites}) {
    for my $ro_server (sort keys %{ $volume_info{ro} }) {
        volume_remove($ro_server, $volume_info{ro}{$ro_server}, "$volume.readonly");
    }
}

volume_remove($volume_info{rwserver}, $volume_info{rwpart}, $volume);

if ($list_file && !$JUSTPRINT) {
    open(my $list_fh, q{>>}, $list_file)
        or exit_with_error("cannot open $list_file: $ERRNO");
    if ($prefix_date) {
        my $today = strftime('%Y-%m-%d', localtime);
        print {$list_fh} "$today $volume\n";
    } else {
        print {$list_fh} $volume, "\n";
    }
    close $list_fh;
    progress("appended $volume to $list_file", 'list-file');
}

if ($mountpoint) {
    $mountpoint = normalize_path($mountpoint);
    my @cmd = ($FS, 'rmmount', '-dir', $mountpoint);
    print "@cmd\n";
    if (!$JUSTPRINT) {
        my ($stdout, $stderr, $rc) = run_command(@cmd);
        if ($rc != 0) {
            exit_with_error("fs rmmount $mountpoint failed (status $rc): $stderr");
        }
        progress("removed mount point $mountpoint", 'fs-rmmount');
    }
}

__END__

##############################################################################
# Documentation
##############################################################################

=for stopwords
AFS -C -FVn -dFhVvn -f -m afs-admin-tools YYYY-MM-DD backend fs lsmount
volnuke vos

=head1 NAME

volnuke - Delete a volume, tracking down what servers it's on

=head1 SYNOPSIS

volnuke [B<-dFhVvn>] [B<-C> I<cell>] [B<--config>=I<path>]
[B<-f> I<list-file>] I<volume>

volnuke [B<-FVn>] [B<-C> I<cell>] [B<--config>=I<path>]
[B<-f> I<list-file>] B<-m> I<mountpoint>

=head1 DESCRIPTION

B<volnuke> is a smart B<vos remove> that figures out what servers the
volume is on to delete it, including replication sites for replicated
volumes.  As a safety measure, it prompts the user whether they're sure
they want to delete the volume, and for replicated volumes it also checks
B<each> replica site individually for recent accesses and unreleased
changes, prompting the user again if any are found.

The volume argument must name a read/write (RW) volume; B<volnuke> will
refuse to operate on a read-only (F<.readonly>) or backup (F<.backup>)
clone directly.  To remove an individual clone, use B<vos remove>
directly.

Normally, B<volnuke> takes a volume as an argument, but with the B<-m>
option it takes a mount point instead and gets the volume name with B<fs
lsmount>, and then removes that mount point when it finishes.

=head1 OPTIONS

=over 4

=item B<-C> I<cell>, B<--cell>=I<cell>

Operate on the named AFS cell.  This value is passed to every B<vos>
command via C<-cell>.  Overrides the built-in default and any C<$CELL>
set in F</etc/afs-admin-tools/config>.

=item B<--config>=I<path>

Path to the configuration file to load, overriding the built-in default
of F</etc/afs-admin-tools/config>.  Useful when operating on a
non-default cell whose configuration lives at a different path.

=item B<-d>, B<--date>

When writing the name of the deleted volume to a file (see the B<-f>
option), prepend the current date as YYYY-MM-DD and then a space to each
line.

=item B<-F>, B<--force>

Don't prompt before deleting the volume.  This option is NOT RECOMMENDED
and is here solely for sysctl/remctl backend scripts.

=item B<-f> I<list-file>, B<--file>=I<list-file>

Append the name of the deleted volume to the given file.  This is used to
accumulate a list of volumes to purge from backups.

=item B<-h>, B<--help>

Print a short summary of the command-line options and exit.

=item B<-M>, B<--manual>

Print this full manual page (by feeding the script to C<perldoc -t>) and
exit.

=item B<-m>, B<--mountpoint>

Rather than a volume name, take the argument to B<volnuke> as a mount
point and get the volume name from B<fs lsmount>.  Also removes the mount
point after B<volnuke> finishes.

=item B<-n>, B<--dry-run>, B<--just-print>

Print out volume status information and the commands that B<volnuke> would
run, but don't execute any of them.

=item B<-V>, B<--verbose>

Show progress messages as B<volnuke> examines the volume, queries each
replica site, removes each site, and cleans up.

=item B<-v>, B<--version>

Print out the version of B<volnuke> and exit.

=back

=head1 CONFIGURATION

B<volnuke> loads configuration settings from
F</etc/afs-admin-tools/config> if that file exists.  If it exists, it must
be Perl code suitable for loading with C<require>.  Each line of the
configuration file should be a bare assignment of the form:

    $VARIABLE = VALUE;

where C<$VARIABLE> is the configuration variable being set and C<VALUE> is
the value to set it to (which should be enclosed in quotes if it's not a
number).  The file should end with:

    1;

so that Perl knows the file was loaded correctly.

The config file is C<require>d from the C<main> package, so a bare
C<$VARIABLE = ...;> assignment sets C<$main::VARIABLE>.  B<volnuke>
declares each supported variable with C<our> so that these assignments
override the built-in defaults.

The supported configuration variables are:

=over 4

=item $FS

The full path to the AFS B<fs> utility.  Defaults to F</usr/bin/fs>.

=item $VOS

The full path to the AFS B<vos> utility.  Defaults to F</usr/bin/vos>.

=item $CELL

The AFS cell to operate in.  Defaults to C<ir.stanford.edu>.

=back

=head1 EXAMPLES

Delete the volume ls.trip.windlord:

    volnuke ls.trip.windlord

The user will be prompted to confirm the action, and possibly prompted
again if the volume is replicated and the read-only replicas have
accesses.

Preview what would be done without actually deleting anything:

    volnuke -n user.jdoe

Delete a volume by naming its mount point instead of its volume name.
The mount point itself is removed after the volume is deleted:

    volnuke -m /afs/ir/user/j/jdoe/old-junk

Delete a volume in a non-default AFS cell:

    volnuke -C otherclass.stanford.edu foo.bar

=head1 AUTHORS

Russ Allbery <rra@stanford.edu> (original)

Adam H. Lewenberg <adamhl@stanford.edu> (2026 rewrite)

=head1 COPYRIGHT AND LICENSE

Copyright 2002, 2003, 2004, 2010, 2011, 2013, 2026 The Board of Trustees
of the Leland Stanford Junior University.

This program is free software; you may redistribute it and/or modify it
under the same terms as Perl itself.

=head1 SEE ALSO

L<fs_lsmount(1)>, L<fs_rmmount(1)>, L<vos(1)>, L<vos_examine(1)>,
L<vos_listvol(1)>, L<vos_remove(1)>

This script is part of the afs-admin-tools package.  The most recent
version is available from the afs-admin-tools web page at
L<http://www.eyrie.org/~eagle/software/afs-admin-tools/>.

=cut
