00822452887965e6220ba83191dca18dd4ddebb1
[debian/amanda] / perl / Amanda / Changer / robot.pm
1 # Copyright (c) 2009,2010 Zmanda, Inc.  All Rights Reserved.
2 #
3 # This library is free software; you can redistribute it and/or modify it
4 # under the terms of the GNU Lesser General Public License version 2.1 as
5 # published by the Free Software Foundation.
6 #
7 # This library is distributed in the hope that it will be useful, but
8 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
9 # or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
10 # License for more details.
11 #
12 # You should have received a copy of the GNU Lesser General Public License
13 # along with this library; if not, write to the Free Software Foundation,
14 # Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307 USA.
15 #
16 # Contact information: Zmanda Inc., 465 S. Mathilda Ave., Suite 300
17 # Sunnyvale, CA 94086, USA, or: http://www.zmanda.com
18
19 package Amanda::Changer::robot;
20
21 use strict;
22 use warnings;
23 use Carp;
24 use vars qw( @ISA );
25 @ISA = qw( Amanda::Changer );
26
27 use Data::Dumper;
28 use File::Path;
29 use Amanda::Paths;
30 use Amanda::MainLoop qw( :GIOCondition make_cb define_steps step );
31 use Amanda::Config qw( :getconf );
32 use Amanda::Debug qw( debug warning );
33 use Amanda::Device qw( :constants );
34 use Amanda::Changer;
35 use Amanda::Constants;
36
37 =head1 NAME
38
39 Amanda::Changer::robot -- control a physical tape changer
40
41 =head1 DESCRIPTION
42
43 This package controls a physical tape changer via 'mtx'.
44
45 See the amanda-changers(7) manpage for usage information.
46
47 =cut
48
49 # NOTES
50 #
51 # This is one of the more sophisticated changers.  Here are some notes that may
52 # help while reading the source code.
53
54 # STATE
55 #
56 # The device state is shared between all changers accessing the same library.
57 # It is a hash with keys:
58 #   slots - see below
59 #   drives - see below
60 #   drive_lru - recently used drives, least recent first
61 #   bc2lb - hash mapping known barcodes to known labels
62 #   current_slot - the current slot
63 #   last_operation_time - time the last operation finished
64 #   last_operation_delay - required delay for that operation
65 #   last_status - last time a 'status' command finished
66 #
67 # The 'slots' key is a hash, with slot numbers as keys and hashes
68 # as values.  Each slot's hash has keys
69 #   state - SLOT_FULL/SLOT_EMPTY/SLOT_UNKNOWN
70 #   device_status - the status of the device
71 #   f_type - The f_type of the header
72 #   label - volume label, if known
73 #   barcode - volume barcode, if available
74 #   loaded_in - drive this volume is loaded in
75 #   ie - 1 if this is an import/export slot
76 # note that this state pretends that a tape physically located
77 # in a tape drive is still located in its original slot.
78 #
79 # The 'drives' key is also a hash by drive numbere, the values of
80 # which are hashes with keys
81 #   state - SLOT_FULL/SLOT_EMPTY/SLOT_UNKNOWN
82 #   label - volume label
83 #   barcode - volume barcode
84 #   orig_slot - slot from which this tape was loaded
85
86 # LOCKING
87 #
88 # This package uses Amanda::Changer's with_locked_state to lock a statefile and
89 # load its contents.  Every time the state is locked, the package also
90 # considers running 'status' to update the state; the status_interval protects
91 # against running status too often.
92 #
93 # Each changer method has an "_unlocked" version that does the actual work, and
94 # is called with an additional 'state' parameter containing the locked state.
95 # This is particularly useful when the load method calls the eject method to
96 # empty a drive that it wants to use.
97
98 # RESERVATIONS
99 #
100 # Reservations are currently represented by a PID in the state file.  If that
101 # pid is no longer running, then the reservation is considered stale and is
102 # discarded (with a warning).
103 #
104 # Reservation objects defer most of the interesting operations back to the
105 # changer itself, since the operations require locked access to the state.
106
107 # INTERFACE
108 #
109 # All of the operating-system-specific functionality is abstracted into the
110 # Interface class.  This is written in such a way that it could be replaced
111 # by a direct SCSI interface.
112
113 sub new {
114     my $class = shift;
115     my ($config, $tpchanger) = @_;
116
117     # strip the "chg-foo:" prefix from $tpchanger
118     my $device_name = $tpchanger;
119     $device_name =~ s/^[^:]*://;
120
121     # get the 'chg-foo' form of this changer script
122     my $class_name = $class;
123     $class_name =~ s/Amanda::Changer::/chg-/;
124
125     my $self = {
126         interface => undef,
127         device_name => $device_name,
128         config => $config,
129
130         # set below from properties
131         statefile => undef,
132         drive2device => {}, # { drive => device name }
133         driveorder => [], # order of tape-device properties
134         drive_choice => 'lru',
135         eject_before_unload => 0,
136         fast_search => 1,
137         use_slots => undef,
138         status_interval => 2, # in seconds
139         load_poll => [0, 2, 120], # delay, poll, until
140         eject_delay => 0, # in seconds
141         unload_delay => 0, # in seconds
142         class_name => $class_name,
143     };
144     bless ($self, $class);
145
146     # handle some config and properties
147     my $properties = $config->{'properties'};
148
149     if (defined $config->{'changerdev'} and $config->{'changerdev'} ne '') {
150         return Amanda::Changer->make_error("fatal", undef,
151             message => "'changerdev' is not allowed with $self->{class_name}");
152     }
153
154     if ($config->{'changerfile'}) {
155         $self->{'statefile'} = Amanda::Config::config_dir_relative($config->{'changerfile'});
156     } else {
157         my $safe_filename = "$self->{class_name}:$device_name";
158         $safe_filename =~ tr/a-zA-Z0-9/-/cs;
159         $safe_filename =~ s/^-*//;
160         $self->{'statefile'} = "$localstatedir/amanda/$safe_filename";
161     }
162     $self->_debug("using statefile '$self->{statefile}'");
163
164     # figure out the drive number to device name mapping
165     if (exists $config->{'tapedev'}
166             and $config->{'tapedev'} ne ''
167             and !exists $properties->{'tape-device'}) {
168         # if the tapedev points to us (the changer), then give an error
169         if ($config->{'tapedev'} eq $tpchanger) {
170             return Amanda::Changer->make_error("fatal", undef,
171                 message => "must specify a tape-device property");
172         }
173         $self->{'drive2device'} = { '0' => $config->{'tapedev'} };
174         push @{$self->{'driveorder'}}, '0';
175     } else {
176         if (!exists $properties->{'tape-device'}) {
177             return Amanda::Changer->make_error("fatal", undef,
178                 message => "no 'tape-device' property specified");
179         }
180         for my $pval (@{$properties->{'tape-device'}->{'values'}}) {
181             my ($drive, $device);
182             unless (($drive, $device) = ($pval =~ /(\d+)=(.*)/)) {
183                 return Amanda::Changer->make_error("fatal", undef,
184                     message => "invalid 'tape-device' property '$pval'");
185             }
186             if (exists $self->{'drive2device'}->{$drive}) {
187                 return Amanda::Changer->make_error("fatal", undef,
188                     message => "tape-device drive $drive defined more than once");
189             }
190             $self->{'drive2device'}->{$drive} = $device;
191             push @{$self->{'driveorder'}}, $drive;
192         }
193     }
194
195     # eject-before-unload
196     my $ebu = $self->get_boolean_property($self->{'config'},
197                                             "eject-before-unload", 0);
198     if (!defined $ebu) {
199         return Amanda::Changer->make_error("fatal", undef,
200             message => "invalid 'eject-before-unload' value");
201     }
202     $self->{'eject_before_unload'} = $ebu;
203
204     # fast-search
205     my $fast_search = $self->get_boolean_property($self->{'config'},
206                                                 "fast-search", 1);
207     if (!defined $fast_search) {
208         return Amanda::Changer->make_error("fatal", undef,
209             message => "invalid 'fast-search' value");
210     }
211     $self->{'fast_search'} = $fast_search;
212
213     # use-slots
214     if (exists $properties->{'use-slots'}) {
215         $self->{'use_slots'} = join ",", @{$properties->{'use-slots'}->{'values'}};
216         if ($self->{'use_slots'} !~ /\d+(-\d+)?(,\d+(-\d+)?)*/) {
217             return Amanda::Changer->make_error("fatal", undef,
218                 message => "invalid 'use-slots' value '$self->{use_slots}'");
219         }
220     }
221
222     # drive-choice
223     if (exists $properties->{'drive-choice'}) {
224         my $pval = $properties->{'drive-choice'}->{'values'}->[0];
225         if (!grep { lc($_) eq $pval } ('lru', 'firstavail')) {
226             return Amanda::Changer->make_error("fatal", undef,
227                 message => "invalid 'drive-choice' value '$pval'");
228         }
229         $self->{'drive_choice'} = $pval;
230     }
231
232     # load-poll
233     {
234         next unless exists $config->{'properties'}->{'load-poll'};
235         if (@{$config->{'properties'}->{'load-poll'}->{'values'}} > 1) {
236             return Amanda::Changer->make_error("fatal", undef,
237                 message => "only one value allowed for 'load-poll'");
238         }
239         my $propval = $config->{'properties'}->{'load-poll'}->{'values'}->[0];
240         my ($delay, $delayu, $poll, $pollu, $until, $untilu) = ($propval =~ /^
241                 (\d+)\s*([ms]?)
242                 (?:
243                   \s+poll\s+
244                   (\d+)\s*([ms]?)
245                   (?:
246                     \s+until\s+
247                     (\d+)\s*([ms]?)
248                   )?
249                 )?
250                 $/ix);
251
252         if (!defined $delay) {
253             return Amanda::Changer->make_error("fatal", undef,
254                 message => "invalid delay value '$propval' for 'load-poll'");
255         }
256
257         $delay *= 60 if (defined $delayu and $delayu =~ /m/i);
258
259         $poll = 0 unless defined $poll;
260         $poll *= 60 if (defined $pollu and $pollu =~ /m/i);
261
262         $until = 0 unless defined $until;
263         $until *= 60 if (defined $untilu and $untilu =~ /m/i);
264
265         $self->{'load_poll'} = [ $delay, $poll, $until ];
266     }
267
268     # status-interval, eject-delay, unload-delay
269     for my $propname qw(status-interval eject-delay unload-delay) {
270         next unless exists $config->{'properties'}->{$propname};
271         if (@{$config->{'properties'}->{$propname}->{'values'}} > 1) {
272             return Amanda::Changer->make_error("fatal", undef,
273                 message => "only one value allowed for $propname");
274         }
275         my $propval = $config->{'properties'}->{$propname}->{'values'}->[0];
276         my ($time, $timeu) = ($propval =~ /^(\d+)([ms]?)/ix);
277
278         if (!defined $time) {
279             return Amanda::Changer->make_error("fatal", undef,
280                 message => "invalid time value '$propval' for '$propname'");
281         }
282
283         $time *= 60 if (defined $timeu and $timeu =~ /m/i);
284
285         my $key = $propname;
286         $key =~ s/-/_/;
287         $self->{$key} = $time;
288     }
289
290     my $ignore_barcodes = $self->get_boolean_property($self->{'config'},
291                                             "ignore-barcodes", 0);
292     if (!defined $ignore_barcodes) {
293         return Amanda::Changer->make_error("fatal", undef,
294             message => "invalid 'ignore-barcodes' value");
295     }
296
297     # get the interface, returning an error if we get one
298     $self->{'interface'} = $self->get_interface($device_name, $ignore_barcodes);
299     return $self->{'interface'}
300         if ($self->{'interface'}->isa("Amanda::Changer::Error"));
301
302     return $self;
303 }
304
305 sub load {
306     my $self = shift;
307     my %params = @_;
308     $self->validate_params('load', \%params);
309
310     return if $self->check_error($params{'res_cb'});
311
312     $self->_with_updated_state(\%params, 'res_cb', sub { $self->load_unlocked(@_) });
313 }
314
315 sub load_unlocked {
316     my $self = shift;
317     my %params = @_;
318     my ($slot, $drive, $need_unload);
319     my $state = $params{'state'};
320
321     my $steps = define_steps
322         cb_ref => \$params{'res_cb'};
323
324     step calculate_slot => sub {
325         # make sure the slot is numeric
326         if (exists $params{'slot'}) {
327             if ($params{'slot'} =~ /^\d+$/) {
328                 $params{'slot'} = $params{'slot'}+0;
329             } else {
330                 return $self->make_error("failed", $params{'res_cb'},
331                         reason => "invalid",
332                         message => "invalid slot '$params{slot}'");
333             }
334         }
335
336         if (exists $params{'relative_slot'}) {
337             if ($params{'relative_slot'} eq "next") {
338                 if (exists $params{'slot'}) {
339                     $slot = $self->_get_next_slot($state, $params{'slot'});
340                     $self->_debug("loading next relative to $params{slot}: $slot");
341                 } else {
342                     $slot = $self->_get_next_slot($state, $state->{'current_slot'});
343                     $self->_debug("loading next relative to current slot: $slot");
344                 }
345                 if ($slot == -1) {
346                     return $self->make_error("failed", $params{'res_cb'},
347                             reason => "invalid",
348                             message => "could not find next slot");
349                 }
350             } elsif ($params{'relative_slot'} eq "current") {
351                 $slot = $state->{'current_slot'};
352                 if ($slot == -1) {
353                     # seek to the first slot
354                     $slot = $self->_get_next_slot($state, $state->{'current_slot'});
355                 }
356                 if ($slot == -1) {
357                     return $self->make_error("failed", $params{'res_cb'},
358                             reason => "invalid",
359                             message => "no current slot");
360                 }
361             } else {
362                 return $self->make_error("failed", $params{'res_cb'},
363                         reason => "invalid",
364                         message => "invalid relative_slot '$params{relative_slot}'");
365             }
366
367         } elsif (exists $params{'slot'}) {
368             $slot = $params{'slot'};
369             $self->_debug("loading slot '$params{slot}'");
370
371             if (!defined $slot or !exists $state->{'slots'}->{$slot}) {
372                 return $self->make_error("failed", $params{'res_cb'},
373                         reason => "invalid",
374                         message => "invalid slot '$slot'");
375             }
376
377         } elsif (exists $params{'label'}) {
378             $self->_debug("loading label '$params{label}'");
379             while (my ($sl, $info) = each(%{$state->{'slots'}})) {
380                 if (defined $info->{'label'} and $info->{'label'} eq $params{'label'}) {
381                     $slot = $sl;
382                     last;
383                 }
384             }
385
386             if (!defined $slot) {
387                 return $self->make_error("failed", $params{'res_cb'},
388                         reason => "notfound",
389                         message => "label '$params{label}' not recognized or not found");
390             }
391
392         } else {
393             return $self->make_error("failed", $params{'res_cb'},
394                     reason => "invalid",
395                     message => "no 'slot' or 'label' specified to load()");
396         }
397
398         if (!$self->_is_slot_allowed($slot)) {
399             if (exists $params{'label'}) {
400                 return $self->make_error("failed", $params{'res_cb'},
401                         reason => "invalid",
402                         message => "label '$params{label}' is in slot $slot, which is " .
403                                    "not in use-slots ($self->{use_slots})");
404             } else {
405                 return $self->make_error("failed", $params{'res_cb'},
406                         reason => "invalid",
407                         message => "slot $slot not in use-slots ($self->{use_slots})");
408             }
409         }
410
411         if (exists $params{'except_slots'} and exists $params{'except_slots'}->{$slot}) {
412             return $self->make_error("failed", $params{'res_cb'},
413                 reason => "notfound",
414                 message => "all slots have been loaded");
415         }
416
417         if ($state->{'slots'}->{$slot}->{'state'} eq Amanda::Changer::SLOT_EMPTY) {
418             return $self->make_error("failed", $params{'res_cb'},
419                     reason => "notfound",
420                     message => "slot $slot is empty");
421         }
422
423         return $steps->{'calculate_drive'}->();
424     };
425
426     step calculate_drive => sub {
427         # $slot is set
428         $need_unload = 0;
429
430         # see if the tape is already in a drive
431         $drive = $state->{'slots'}->{$slot}->{'loaded_in'};
432         if (defined $drive) {
433             $self->_debug("requested volume is already in drive $drive");
434             my $info = $state->{'drives'}->{$drive};
435
436             # if it's reserved, it can't be used
437             if ($info->{'res_info'} and $self->_res_info_verify($info->{'res_info'})) {
438                 return $self->make_error("failed", $params{'res_cb'},
439                         reason => "volinuse",
440                         slot => $slot,
441                         message => "the requested volume is in use (drive $drive)");
442             }
443
444             # if it's not reserved, but not in our list of drives, well, it still
445             # can't be used
446             if (!exists $self->{'drive2device'}->{$drive}) {
447                 return $self->make_error("failed", $params{'res_cb'},
448                         # not 'volinuse' because we can't expect the tape to be magically
449                         # unloaded any time soon -- it's not actually in use, just inaccessible
450                         reason => "invalid",
451                         message => "the requested volume is in drive $drive, which this " .
452                                    "changer instance cannot access");
453             }
454
455             # otherwise, we can jump all the way to the end of this process
456             return $steps->{'check_device'}->();
457         }
458
459         # here is where we implement each of the drive-selection algorithms
460         my @check_order;
461         if ($self->{'drive_choice'} eq 'lru') {
462             my %lru = map { $_, 1 } @{$state->{'drive_lru'}};
463             my @unused = grep { ! exists $lru{$_} } @{$self->{'driveorder'}};
464
465             # search through unused drives, then the LRU list
466             @check_order = (@unused, @{$state->{'drive_lru'}});
467         } elsif ($self->{'drive_choice'} eq 'firstavail') {
468             # just the drive order, so we tend to prefer the first drive in
469             # this order
470             @check_order = (@{$self->{'driveorder'}});
471         } else {
472             # the constructor should detect this circumstance
473             die "invalid drive_choice";
474         }
475
476         my %checked;
477         for my $dr (@check_order) {
478             my $info = $state->{'drives'}->{$dr};
479             next unless defined $info;
480             next if exists $checked{$dr}; # don't check drives repeatedly
481             $checked{$dr} = 1;
482
483             # skip drives we don't have rights to use
484             next unless exists $self->{'drive2device'}->{$dr};
485
486             # skip reserved drives
487             if ($info->{'res_info'}) {
488                 if ($self->_res_info_verify($info->{'res_info'})) {
489                     # this is a valid reservation -> skip this drive
490                     $self->_debug("skipping drive $dr - already reserved");
491                     next;
492                 } else {
493                     warning("invalidating stale reservation on drive $dr");
494                     $info->{'res_info'} = undef;
495                 }
496             }
497
498             # otherwise, the drive is available, so use it (whether it contains
499             # a volume or not)
500             $drive = $dr;
501             if ($info->{'state'} != Amanda::Changer::SLOT_EMPTY) {
502                 $need_unload = 1;
503             }
504             last;
505         }
506
507         if (!defined $drive) {
508             return $self->make_error("failed", $params{'res_cb'},
509                     reason => "driveinuse",
510                     message => "no drives available");
511         }
512
513         # remove this drive from the lru and put it at the end
514         $state->{'drive_lru'} = [ grep { $_ ne $drive } @{$state->{'drive_lru'}} ];
515         push @{$state->{'drive_lru'}}, $drive;
516
517         $self->_debug("using drive $drive");
518
519         $steps->{'wait_to_start'}->();
520     };
521
522     step wait_to_start => sub {
523         $self->_after_delay($state, $steps->{'start_operation'});
524     };
525
526     step start_operation => sub {
527         # $need_unload is set in $steps->{calculate_drive}
528         if ($need_unload) {
529             $steps->{'start_eject'}->();
530         } else {
531             $steps->{'start_load'}->();
532         }
533     };
534
535     step start_eject => sub {
536         # we use the 'eject' method to unload here -- it ejects the volume
537         # if the configuration calls for it, then puts the volume away in its
538         # original slot.
539         $self->eject_unlocked(
540                 finished_cb => $steps->{'eject_finished'},
541                 drive => $drive,
542                 state => $state);
543     };
544
545     step eject_finished => sub {
546         my ($err) = @_;
547
548         if ($err) {
549            return $params{'res_cb'}->($err);
550         }
551
552         $steps->{'wait_to_load'}->();
553     };
554
555     step wait_to_load => sub {
556         $self->_after_delay($state, $steps->{'start_load'});
557     };
558
559     step start_load => sub {
560         # $slot and $drive are set
561         $self->{'interface'}->load($slot, $drive, $steps->{'load_finished'});
562     };
563
564     step load_finished => sub {
565         # $slot and $drive are set
566         my ($err) = @_;
567
568         if ($err) {
569             return $self->make_error("failed", $params{'res_cb'},
570                     reason => "unknown",
571                     message => $err);
572         }
573
574         $steps->{'start_polling'}->();
575     };
576
577     my ($next_poll, $last_poll);
578     step start_polling => sub {
579         my ($delay, $poll, $until) = @{ $self->{'load_poll'} };
580         my $now = time;
581         $next_poll = $now + $delay;
582         $last_poll = $now + $until;
583
584         return Amanda::MainLoop::call_after(1000 * ($next_poll - $now), $steps->{'check_device'});
585     };
586
587     step check_device => sub {
588         my $device_name = $self->{'drive2device'}->{$drive};
589         die "drive $drive not found in drive2device" unless $device_name; # shouldn't happen
590
591         $self->_debug("polling '$device_name' to see if it's ready");
592
593         my $device = $self->get_device($device_name);
594         return $params{'res_cb'}->($device) if $device->isa("Amanda::Changer::Error");
595
596         my $label;
597         $device->read_label();
598
599         # see if the device thinks it's possible it's busy or empty
600         if ($device->status & $DEVICE_STATUS_VOLUME_MISSING
601             or $device->status & $DEVICE_STATUS_DEVICE_BUSY) {
602             # device is not ready -- set up for the next polling step
603             my ($delay, $poll, $until) = @{ $self->{'load_poll'} };
604             my $now = time;
605             $next_poll += $poll;
606             $next_poll = $now + 1 if ($next_poll < $now);
607             if ($poll != 0 and $next_poll < $last_poll) {
608                 return Amanda::MainLoop::call_after(
609                         1000 * ($next_poll - $now), $steps->{'check_device'});
610             }
611
612             # (fall through if we're done polling)
613         }
614
615         if ($device->status == $DEVICE_STATUS_SUCCESS) {
616             $label = $device->volume_label;
617         } elsif ($device->status & $DEVICE_STATUS_VOLUME_UNLABELED) {
618             $label = undef;
619         } else {
620             return $self->make_error("fatal", $params{'res_cb'},
621                     message => "while waiting for '$device_name' to become ready: "
622                         . $device->error_or_status());
623         }
624
625         # success!
626         $steps->{'make_res'}->($device, $label);
627     };
628
629     step make_res => sub {
630         my ($device, $label) = @_;
631
632         # check the label against the desired label, in case this isn't the
633         # desired volume
634         if ($label and $params{'label'} and $label ne $params{'label'}) {
635             $self->_debug("Expected label '$params{label}', but got '$label'");
636
637             # update metadata with this new information
638             $state->{'slots'}->{$slot}->{'state'} = Amanda::Changer::SLOT_FULL;
639             $state->{'slots'}->{$slot}->{'device_status'} = $device->status;
640             if (defined $device->{'header'}) {
641                 $state->{'slots'}->{$slot}->{'f_type'} = $device->{'header'}->{type};
642             } else {
643                 $state->{'slots'}->{$slot}->{'f_type'} = undef;
644             }
645             $state->{'slots'}->{$slot}->{'label'} = $label;
646             if ($state->{'slots'}->{$slot}->{'barcode'}) {
647                 $state->{'bc2lb'}->{$state->{'slots'}->{$slot}->{'barcode'}} = $label;
648             }
649
650             return $self->make_error("failed", $params{'res_cb'},
651                     reason => "notfound",
652                     message => "Found unexpected tape '$label' while looking " .
653                                "for '$params{label}'");
654         }
655
656         if (!$label and $params{'label'}) {
657             $self->_debug("Expected label '$params{label}', but got an unlabeled tape");
658
659             # update metadata with this new information
660             $state->{'slots'}->{$slot}->{'state'} = Amanda::Changer::SLOT_FULL;
661             $state->{'slots'}->{$slot}->{'device_status'} = $device->status;
662             if (defined $device->{'header'}) {
663                 $state->{'slots'}->{$slot}->{'f_type'} = $device->{'header'}->{type};
664             } else {
665                 $state->{'slots'}->{$slot}->{'f_type'} = undef;
666             }
667             $state->{'slots'}->{$slot}->{'label'} = undef;
668             if ($state->{'slots'}->{$slot}->{'barcode'}) {
669                 delete $state->{'bc2lb'}->{$state->{'slots'}->{$slot}->{'barcode'}};
670             }
671
672             return $self->make_error("failed", $params{'res_cb'},
673                     reason => "notfound",
674                     message => "Found unlabeled tape while looking for '$params{label}'");
675         }
676
677         my $res = Amanda::Changer::robot::Reservation->new($self, $slot, $drive,
678                                 $device, $state->{'slots'}->{$slot}->{'barcode'});
679
680         # mark this as reserved
681         $state->{'drives'}->{$drive}->{'res_info'} = $self->_res_info_new();
682
683         # update our state before returning
684         $state->{'slots'}->{$slot}->{'loaded_in'} = $drive;
685         $state->{'drives'}->{$drive}->{'orig_slot'} = $slot;
686         $state->{'slots'}->{$slot}->{'label'} = $label;
687         $state->{'drives'}->{$drive}->{'label'} = $label;
688         $state->{'drives'}->{$drive}->{'state'} = Amanda::Changer::SLOT_FULL;
689         $state->{'drives'}->{$drive}->{'barcode'} = $state->{'slots'}->{$slot}->{'barcode'};
690         #$state->{'slots'}->{$slot}->{'device_status'} = 9;
691         if ($label and $state->{'slots'}->{$slot}->{'barcode'}) {
692             $state->{'bc2lb'}->{$state->{'slots'}->{$slot}->{'barcode'}} = $label;
693         }
694         if ($params{'set_current'}) {
695                 $self->_debug("setting current slot to $slot");
696             $state->{'current_slot'} = $slot;
697         }
698
699         return $params{'res_cb'}->(undef, $res);
700     };
701 }
702
703 sub info_key {
704     my $self = shift;
705     my ($key, %params) = @_;
706
707     if ($key eq 'fast_search') {
708         $self->info_key_fast_search(%params);
709     } elsif ($key eq 'vendor_string') {
710         $self->info_key_vendor_string(%params);
711     } elsif ($key eq 'num_slots') {
712         $self->info_key_num_slots(%params);
713     }
714 }
715
716 sub info_key_fast_search {
717     my $self = shift;
718     my %params = @_;
719
720     $params{'info_cb'}->(undef,
721         fast_search => $self->{'fast_search'},
722     );
723 }
724
725 sub info_key_vendor_string {
726     my $self = shift;
727     my %params = @_;
728
729     $self->{'interface'}->inquiry(make_cb(inquiry_cb => sub {
730         my ($err, $info) = @_;
731         return $params{'info_cb'}->($err) if $err;
732
733         my $vendor_string = sprintf "%s %s",
734             ($info->{'vendor id'} or "<unknown>"),
735             ($info->{'product id'} or "<unknown>");
736
737         $params{'info_cb'}->(undef,
738             vendor_string => $vendor_string,
739         );
740     }));
741 }
742
743 sub info_key_num_slots {
744     my $self = shift;
745     my %params = @_;
746
747     $self->_with_updated_state(\%params, 'info_cb',
748         sub { $self->info_key_num_slots_unlocked(@_) });
749 }
750
751 sub info_key_num_slots_unlocked {
752     my $self = shift;
753     my %params = @_;
754     my $state = $params{'state'};
755
756     my @allowed_slots = grep { $self->_is_slot_allowed($_) }
757                         keys %{$state->{'slots'}};
758
759     $params{'info_cb'}->(undef, num_slots => scalar @allowed_slots);
760 }
761
762 sub get_interface { # (overridden by subclasses)
763     my $self = shift;
764     my ($device_name, $ignore_barcodes) = @_;
765
766     my $mtx;
767     if (exists $self->{'config'}->{'properties'}->{'mtx'}) {
768         if (@{$self->{'config'}->{'properties'}->{'mtx'}->{'values'}} > 1) {
769             return Amanda::Changer->make_error("fatal", undef,
770                 message => "only one value allowed for 'mtx'");
771         }
772         $mtx = $self->{'config'}->{'properties'}->{'mtx'}->{'values'}->[0];
773     } else {
774         $mtx = $Amanda::Constants::MTX;
775     }
776
777     if (!$mtx) {
778         return Amanda::Changer->make_error("fatal", undef,
779             message => "no default value for property MTX");
780     }
781
782     return Amanda::Changer::robot::Interface::MTX->new($device_name, $mtx, $ignore_barcodes),
783 }
784
785 # get, configure, and return a new device, or return a changer error
786 sub get_device { # (overridden by subclasses)
787     my $self = shift;
788     my ($device_name) = @_;
789
790     my $device = Amanda::Device->new($device_name);
791     if ($device->status != $DEVICE_STATUS_SUCCESS) {
792         return $self->make_error("failed", undef,
793                 reason => "device",
794                 message => "opening '$device_name': " . $device->error_or_status());
795     }
796
797     if (my $err = $self->{'config'}->configure_device($device)) {
798         return $self->make_error("failed", undef,
799                 reason => "device",
800                 message => $err);
801     }
802
803     return $device;
804 }
805
806 sub _set_label {
807     my $self = shift;
808     my %params = @_;
809
810     return if $self->check_error($params{'finished_cb'});
811
812     $self->_with_updated_state(\%params, 'finished_cb',
813         sub { $self->_set_label_unlocked(@_); });
814 }
815
816 sub _set_label_unlocked {
817     my $self = shift;
818     my %params = @_;
819     my $state = $params{'state'};
820
821     # update all of the various pieces of cached information
822     my $drive = $params{'drive'};
823     my $slot = $state->{'drives'}->{$drive}->{'orig_slot'};
824     my $label = $params{'label'};
825     my $barcode = $state->{'drives'}->{$drive}->{'barcode'};
826     my $dev = $params{dev};
827
828     $state->{'drives'}->{$drive}->{'label'} = $label;
829     if (defined $slot) {
830         delete $state->{'slots'}->{$slot}->{'unkknown_state'};
831         $state->{'slots'}->{$slot}->{'state'} = Amanda::Changer::SLOT_FULL;
832         $state->{'slots'}->{$slot}->{'device_status'} = "".$dev->status;
833         my $volume_header = $dev->volume_header;
834         if (defined $volume_header) {
835             $state->{'slots'}->{$slot}->{'f_type'} = "".$volume_header->{type};
836         } else {
837             $state->{'slots'}->{$slot}->{'f_type'} = undef;
838         }
839         $state->{'slots'}->{$slot}->{'label'} = $label;
840     }
841     if (defined $barcode) {
842         $state->{'bc2lb'}->{$barcode} = $label;
843     }
844
845     $params{'finished_cb'}->(undef);
846 }
847
848 sub _release {
849     my $self = shift;
850     my %params = @_;
851
852     return if $self->check_error($params{'finished_cb'});
853
854     $self->_with_updated_state(\%params, 'finished_cb',
855         sub { $self->_release_unlocked(@_); });
856 }
857
858 sub _release_unlocked {
859     my $self = shift;
860     my %params = @_;
861     my $state = $params{'state'};
862     my $drive = $params{'drive'};
863
864     # delete the reservation and save the statefile
865     if (!$self->_res_info_is_mine($state->{'drives'}->{$drive}->{'res_info'})) {
866         # this should *never* happen
867         return $self->make_error("fatal", $params{'finished_cb'},
868                 message => "reservation belongs to another instance");
869     }
870     $state->{'drives'}->{$drive}->{'res_info'} = undef;
871
872     # bounce off to eject if the user has requested it, using the xx_unlocked
873     # variant since we've already got the statefile open
874     if ($params{'eject'}) {
875         $self->eject_unlocked(
876             drive => $drive,
877             finished_cb => $params{'finished_cb'},
878             state => $state,
879         );
880     } else {
881         $params{'finished_cb'}->();
882     }
883 }
884
885 sub reset {
886     my $self = shift;
887     my %params = @_;
888
889     return if $self->check_error($params{'finished_cb'});
890
891     $self->_with_updated_state(\%params, 'finished_cb',
892         sub { $self->reset_unlocked(@_); });
893 }
894
895 sub reset_unlocked {
896     my $self = shift;
897     my %params = @_;
898     my $state = $params{'state'};
899
900     $state->{'current_slot'} = $self->_get_next_slot($state, -1);
901
902     $params{'finished_cb'}->();
903 }
904
905 sub eject {
906     my $self = shift;
907     my %params = @_;
908
909     return if $self->check_error($params{'finished_cb'});
910
911     $self->_with_updated_state(\%params, 'finished_cb',
912         sub { $self->eject_unlocked(@_); });
913 }
914
915 sub eject_unlocked {
916     my $self = shift;
917     my %params = @_;
918     my $state = $params{'state'};
919     my ($drive, $drive_info);
920
921     return if $self->check_error($params{'finished_cb'});
922
923     my $steps = define_steps
924         cb_ref => \$params{'finished_cb'};
925
926     # note that this changer treats "eject" as "unload", which may also require an eject
927     # operation if the eject_before_unload property is set
928
929     step start => sub {
930         # if drive isn't specified, see if we only have one
931         if (!exists $params{'drive'}) {
932             if ((keys %{$self->{'drive2device'}}) == 1) {
933                 $params{'drive'} = (keys %{$self->{'drive2device'}})[0];
934             } else {
935                 return $self->make_error("failed", $params{'finished_cb'},
936                         reason => "invalid",
937                         message => "no drive specified");
938             }
939         }
940         $drive = $params{'drive'};
941
942         $self->_debug("unloading drive $drive");
943         $drive_info = $state->{'drives'}->{$drive};
944         if (!$drive_info) {
945             return $self->make_error("failed", $params{'finished_cb'},
946                     reason => "invalid",
947                     message => "invalid drive '$drive'");
948         }
949
950         # if the drive exists, but not configured in this changer, then
951         # bail out.
952         if (!defined $self->{'drive2device'}->{$drive}) {
953             return $self->make_error("failed", $params{'finished_cb'},
954                     reason => "invalid",
955                     message => "this changer instance is not configured to access drive $drive");
956         }
957
958
959         # check for a reservation
960         if ($drive_info->{'res_info'}
961                     and $self->_res_info_verify($drive_info->{'res_info'})) {
962             return $self->make_error("failed", $params{'finished_cb'},
963                     reason => "volinuse",
964                     message => "tape in drive '$drive' is in use");
965         }
966
967         if ($self->{'eject_before_unload'}) {
968             $steps->{'wait_to_eject'}->();
969         } else {
970             $steps->{'wait_to_unload'}->();
971         }
972     };
973
974     step wait_to_eject => sub {
975         $self->_after_delay($state, $steps->{'eject'});
976     };
977
978     step eject => sub {
979         my $device_name = $self->{'drive2device'}->{$drive};
980         $self->_debug("ejecting $device_name before unload");
981
982         my $device = $self->get_device($device_name);
983         return $device if $device->isa("Amanda::Changer::Error");
984
985         if (!$device->eject()) {
986             return $self->make_error("failed", $params{'finished_cb'},
987                     reason => "unknown",
988                     message => "while ejecting volume: " . $device->error_or_status);
989         }
990         undef $device;
991
992         $self->_set_delay($state, $self->{'eject_delay'});
993
994         $steps->{'wait_to_unload'}->();
995     };
996
997     step wait_to_unload => sub {
998         $self->_after_delay($state, $steps->{'unload'});
999     };
1000
1001     step unload => sub {
1002         # find target slot and unload it - note that the target slot may not be
1003         # in the USE-SLOTS list, as it may belong to another config
1004         my $orig_slot = $drive_info->{'orig_slot'};
1005         $self->{'interface'}->unload($drive, $orig_slot, $steps->{'unload_finished'});
1006     };
1007
1008     step unload_finished => sub {
1009         my ($err) = @_;
1010
1011         if ($err) {
1012             return $self->make_error("failed", $params{'finished_cb'},
1013                     reason => "unknown",
1014                     message => $err);
1015         }
1016
1017         $self->_debug("unload complete");
1018         my $orig_slot = $state->{'drives'}->{$drive}->{'orig_slot'};
1019         $state->{'slots'}->{$orig_slot}->{'state'} = $state->{'drives'}->{$drive}->{'state'};
1020         $state->{'slots'}->{$orig_slot}->{'label'} = $state->{'drives'}->{$drive}->{'label'};
1021         $state->{'slots'}->{$orig_slot}->{'barcode'} = $state->{'drives'}->{$drive}->{'barcode'};
1022         $state->{'slots'}->{$orig_slot}->{'loaded_in'} = undef;
1023         $state->{'drives'}->{$drive}->{'state'} = Amanda::Changer::SLOT_EMPTY;
1024         $state->{'drives'}->{$drive}->{'label'} = undef;
1025         $state->{'drives'}->{$drive}->{'barcode'} = undef;
1026         $state->{'drives'}->{$drive}->{'orig_slot'} = undef;
1027
1028         $self->_set_delay($state, $self->{'unload_delay'});
1029         $params{'finished_cb'}->();
1030     };
1031 }
1032
1033 sub update {
1034     my $self = shift;
1035     my %params = @_;
1036
1037     return if $self->check_error($params{'finished_cb'});
1038
1039     $self->_with_updated_state(\%params, 'finished_cb',
1040         sub { $self->update_unlocked(@_); });
1041 }
1042
1043 sub update_unlocked {
1044     my $self = shift;
1045     my %params = @_;
1046     my @slots_to_check;
1047     my $state = $params{'state'};
1048     my $set_to_unknown = 0;
1049
1050     return if $self->check_error($params{'finished_cb'});
1051
1052     my $user_msg_fn = $params{'user_msg_fn'};
1053     $user_msg_fn ||= sub { $self->_debug($_[0]); };
1054
1055     my $steps = define_steps
1056         cb_ref => \$params{'finished_cb'};
1057
1058     step handle_assignment => sub {
1059         # check for the SL=LABEL format, and handle it here
1060         if (exists $params{'changed'} and $params{'changed'} =~ /^\d+=\S+$/) {
1061             my ($slot, $label) = ($params{'changed'} =~ /^(\d+)=(\S+)$/);
1062
1063             # let's list the reasons we *can't* do what the user has asked
1064             my $whynot;
1065             if (!exists $state->{'slots'}) {
1066                 $whynot = "slot $slot does not exist";
1067             } elsif (!$self->_is_slot_allowed($slot)) {
1068                 $whynot = "slot $slot is not used by this changer";
1069             } elsif ($state->{'slots'}->{$slot}->{'state'} ==
1070                      Amanda::Changer::SLOT_EMPTY) {
1071                 $whynot = "slot $slot is empty";
1072             } elsif (defined $state->{'slots'}->{$slot}->{'loaded_in'}) {
1073                 $whynot = "slot $slot is currently loaded";
1074             }
1075
1076             if ($whynot) {
1077                 return $self->make_error("failed", $params{'finished_cb'},
1078                         reason => "unknown", message => $whynot);
1079             }
1080
1081             $user_msg_fn->("recoding volume '$label' in slot $slot");
1082             # ok, now erase all knowledge of that label
1083             while (my ($bc, $lb) = each %{$state->{'bc2lb'}}) {
1084                 if ($lb eq $label) {
1085                     delete $state->{'bc2lb'}->{$bc};
1086                     last;
1087                 }
1088             }
1089             while (my ($sl, $inf) = each %{$state->{'slots'}}) {
1090                 if ($inf->{'label'} and $inf->{'label'} eq $label) {
1091                     delete $inf->{'device_status'};
1092                     delete $inf->{'f_type'};
1093                     delete $inf->{'label'};
1094                 }
1095             }
1096
1097             # and add knowledge of the label to the given slot
1098             #$state->{'slots'}->{$slot}->{'device_status'} = $DEVICE_STATUS_SUCCESS;
1099             #$state->{'slots'}->{$slot}->{'f_type'} = $Amanda::Header::F_TAPESTART;
1100             $state->{'slots'}->{$slot}->{'label'} = $label;
1101             if ($state->{'slots'}->{$slot}->{'barcode'}) {
1102                 my $bc = $state->{'slots'}->{$slot}->{'barcode'};
1103                 $state->{'bc2lb'}->{$bc} = $label;
1104             }
1105
1106             # that's it -- no changer motion required
1107             return $params{'finished_cb'}->(undef);
1108         } elsif (exists $params{'changed'} and
1109                  $params{'changed'} =~ /^(.+)=$/) {
1110             $params{'changed'} = $1;
1111             $set_to_unknown = 1;
1112             $steps->{'calculate_slots'}->($steps->{'set_to_unknown'});
1113         } else {
1114             $steps->{'calculate_slots'}->($steps->{'update_slot'});
1115         }
1116     };
1117
1118     step calculate_slots => sub {
1119         my ($update_slot_cb) = shift @_;
1120         if (exists $params{'changed'}) {
1121             # parse the string just like use-slots, using a hash for uniqueness
1122             my %changed;
1123             for my $range (split ',', $params{'changed'}) {
1124                 my ($first, $last) = ($range =~ /(\d+)(?:-(\d+))?/);
1125                 $last = $first unless defined($last);
1126                 for ($first .. $last) {
1127                     $changed{$_} = undef;
1128                 }
1129             }
1130
1131             @slots_to_check = keys %changed;
1132             @slots_to_check = grep { exists $state->{'slots'}->{$_} } @slots_to_check;
1133         } else {
1134             @slots_to_check = keys %{ $state->{'slots'} };
1135         }
1136
1137         # limit the update to allowed slots, and sort them so we don't confuse
1138         # the user with a "random" order
1139         @slots_to_check = grep { $self->_is_slot_allowed($_) } @slots_to_check;
1140         @slots_to_check = grep { $state->{'slots'}->{$_}->{'state'} == Amanda::Changer::SLOT_FULL} @slots_to_check;
1141         @slots_to_check = sort { $a <=> $b } @slots_to_check;
1142
1143         $update_slot_cb->();
1144     };
1145
1146     step set_to_unknown => sub {
1147         return $steps->{'done'}->() if (!@slots_to_check);
1148
1149         my $slot = shift @slots_to_check;
1150         $user_msg_fn->("Removing entry for slot $slot");
1151         if (!defined $state->{'slots'}->{$slot}->{'barcode'}) {
1152             $state->{'slots'}->{$slot}->{'label'} = undef;
1153             $state->{'slots'}->{$slot}->{'device_status'} = undef;
1154             $state->{'slots'}->{$slot}->{'f_type'} = undef;
1155             if (defined $state->{'slots'}->{$slot}->{'loaded_in'}) {
1156                 my $drive = $state->{'slots'}->{$slot}->{'loaded_in'};
1157                 $state->{'drives'}->{$drive}->{'label'} = undef;
1158                 $state->{'drives'}->{$drive}->{'state'} =
1159                                         Amanda::Changer::SLOT_FULL;
1160             }
1161         }
1162         $steps->{'set_to_unknown'}->();
1163     };
1164
1165     # TODO: parallelize this if multiple drives are available
1166
1167     step update_slot => sub {
1168         return $steps->{'done'}->() if (!@slots_to_check);
1169
1170         my $slot = shift @slots_to_check;
1171         $user_msg_fn->("scanning slot $slot");
1172
1173         $self->load_unlocked(
1174                 slot => $slot,
1175                 res_cb => $steps->{'slot_loaded'},
1176                 state => $state);
1177     };
1178
1179     step slot_loaded => sub {
1180         my ($err, $res) = @_;
1181         if ($err) {
1182             return $params{'finished_cb'}->($err);
1183         }
1184
1185         # load() already fixed up the metadata, so just release; but we have to
1186         # be careful to do an unlocked release.
1187         $res->release(
1188             finished_cb => $steps->{'released'},
1189             unlocked => 1,
1190             state => $state);
1191     };
1192
1193     step released => sub {
1194         my ($err) = @_;
1195         if ($err) {
1196             return $params{'finished_cb'}->($err);
1197         }
1198
1199         $steps->{'update_slot'}->();
1200     };
1201
1202     step done => sub {
1203         $params{'finished_cb'}->(undef);
1204     };
1205 }
1206
1207 sub inventory {
1208     my $self = shift;
1209     my %params = @_;
1210
1211     return if $self->check_error($params{'inventory_cb'});
1212
1213     $self->_with_updated_state(\%params, 'inventory_cb',
1214         sub { $self->inventory_unlocked(@_); });
1215 }
1216
1217 sub inventory_unlocked {
1218     my $self = shift;
1219     my %params = @_;
1220     my $state = $params{'state'};
1221
1222     my @slot_names = sort { $a <=> $b } keys %{ $state->{'slots'} };
1223     my @inv;
1224     for my $slot_name (@slot_names) {
1225         my $i = {};
1226         next unless $self->_is_slot_allowed($slot_name);
1227         my $slot = $state->{'slots'}->{$slot_name};
1228
1229         $i->{'slot'} = $slot_name;
1230         $i->{'state'} = $slot->{'state'};
1231         $i->{'device_status'} = $slot->{'device_status'};
1232         $i->{'f_type'} = $slot->{'f_type'};
1233         $i->{'label'} = $slot->{'label'};
1234         $i->{'barcode'} = $slot->{'barcode'}
1235                 if ($slot->{'barcode'});
1236         if (defined $slot->{'loaded_in'}) {
1237             $i->{'loaded_in'} = $slot->{'loaded_in'};
1238             my $drive = $state->{'drives'}->{$slot->{'loaded_in'}};
1239             if ($drive->{'res_info'} and $self->_res_info_verify($drive->{'res_info'})) {
1240                 $i->{'reserved'} = 1;
1241             }
1242         }
1243         $i->{'import_export'} = 1
1244             if $slot->{'ie'};
1245
1246         $i->{'current'} = 1
1247             if $slot_name eq $state->{'current_slot'};
1248
1249         push @inv, $i;
1250     }
1251
1252     $params{'inventory_cb'}->(undef, \@inv);
1253 }
1254
1255 sub move {
1256     my $self = shift;
1257     my %params = @_;
1258
1259     return if $self->check_error($params{'finished_cb'});
1260
1261     $self->_with_updated_state(\%params, 'finished_cb',
1262         sub { $self->move_unlocked(@_); });
1263 }
1264
1265 sub move_unlocked {
1266     my $self = shift;
1267     my %params = @_;
1268     my $state = $params{'state'};
1269
1270     my $from_slot = $params{'from_slot'};
1271     my $to_slot = $params{'to_slot'};
1272
1273     # make sure this is OK
1274     for ($from_slot, $to_slot) {
1275         if (!$self->_is_slot_allowed($_)) {
1276             return $self->make_error("failed", $params{'finished_cb'},
1277                     reason => "invalid",
1278                     message => "invalid slot $_");
1279         }
1280     }
1281
1282     if ($state->{'slots'}->{$from_slot}->{'state'} == Amanda::Changer::SLOT_EMPTY) {
1283         return $self->make_error("failed", $params{'finished_cb'},
1284                 reason => "invalid",
1285                 message => "slot $from_slot is empty");
1286     }
1287
1288     if (defined $state->{'slots'}->{$from_slot}->{'loaded_in'}) {
1289         return $self->make_error("failed", $params{'finished_cb'},
1290                 reason => "invalid",
1291                 message => "slot $from_slot is currently loaded");
1292     }
1293
1294     if ($state->{'slots'}->{$to_slot}->{'state'} == Amanda::Changer::SLOT_FULL) {
1295         return $self->make_error("failed", $params{'finished_cb'},
1296                 reason => "invalid",
1297                 message => "slot $to_slot is not empty");
1298     }
1299
1300     # if the destination slot is loaded, then we could do an "exchange", but
1301     # should we?
1302
1303     my $transfer_complete = make_cb(transfer_complete => sub {
1304         my ($err) = @_;
1305         return $params{'finished_cb'}->($err) if $err;
1306
1307         # update metadata
1308         $state->{'slots'}->{$to_slot} = { %{ $state->{'slots'}->{$from_slot} } };
1309         $state->{'slots'}->{$from_slot}->{'state'} =
1310                                                 Amanda::Changer::SLOT_EMPTY;
1311         $state->{'slots'}->{$from_slot}->{'device_status'} = undef;
1312         $state->{'slots'}->{$from_slot}->{'f_type'} = undef;
1313         $state->{'slots'}->{$from_slot}->{'label'} = undef;
1314         $state->{'slots'}->{$from_slot}->{'barcode'} = undef;
1315
1316         $params{'finished_cb'}->();
1317     });
1318     $self->{'interface'}->transfer($from_slot, $to_slot, $transfer_complete);
1319 }
1320
1321 ##
1322 # Utilities
1323
1324 # calculate the next highest non-empty slot after $slot (assuming that
1325 # the changer status has been updated)
1326 sub _get_next_slot {
1327     my $self = shift;
1328     my ($state, $slot) = @_;
1329
1330     my @nonempty = sort { $a <=> $b } grep {
1331         $state->{'slots'}->{$_}->{'state'} == Amanda::Changer::SLOT_FULL
1332         and $self->_is_slot_allowed($_)
1333     } keys(%{$state->{'slots'}});
1334
1335     my @higher = grep { $_ > $slot } @nonempty;
1336
1337     # return the next higher slot, or the first nonempty slot (to loop around)
1338     return $higher[0] if (@higher);
1339     return $nonempty[0];
1340 }
1341
1342 # is $slot in the slots specified by the use-slots property?
1343 sub _is_slot_allowed {
1344     my $self = shift;
1345     my ($slot) = @_;
1346
1347     # if use-slots is not specified, all slots are available
1348     return 1 unless ($self->{'use_slots'});
1349
1350     for my $range (split ',', $self->{'use_slots'}) {
1351         my ($first, $last) = ($range =~ /(\d+)(?:-(\d+))?/);
1352         $last = $first unless defined($last);
1353         return 1 if ($slot >= $first and $slot <= $last);
1354     }
1355
1356     return 0;
1357 }
1358
1359 # add a prefix and call Amanda::Debug::debug
1360 sub _debug {
1361     my $self = shift;
1362     my ($msg) = @_;
1363     # chg_name is not set until *after* the constructor finishes
1364     my $chg_name = $self->{'chg_name'} || $self->{'class_name'};
1365     debug("$chg_name: $msg");
1366 }
1367
1368 ##
1369 # Timing management
1370
1371 # Wait until the delay from the last operation has expired, and call the
1372 # given callback with the given arguments
1373 sub _after_delay {
1374     my $self = shift;
1375     my ($state, $cb, @args) = @_;
1376
1377     confess("undefined \$cb") unless (defined $cb);
1378
1379     # if the current time is before $start, then we'll perform the action anyway; this
1380     # saves us from long delays when clocks fall out of sync or run backward, but delays
1381     # are short.
1382     my ($start, $end, $now);
1383     $start = $state->{'last_operation_time'};
1384     if (!defined $start) {
1385         return $cb->(@args);
1386     }
1387
1388     $end = $start + $state->{'last_operation_delay'};
1389     $now = time;
1390
1391     if ($now >= $start and $now < $end) {
1392         Amanda::MainLoop::call_after(1000 * ($end - $now), $cb, @args);
1393     } else {
1394         return $cb->(@args);
1395     }
1396 }
1397
1398 # set the delay parameters in the statefile
1399 sub _set_delay {
1400     my $self = shift;
1401     my ($state, $delay) = @_;
1402
1403     $state->{'last_operation_time'} = time;
1404     $state->{'last_operation_delay'} = $delay;
1405 }
1406
1407 ##
1408 # Statefile management
1409
1410 # wrapper around Amanda::Changer's with_locked_state to lock the statefile and
1411 # then update the state with the results of the 'status' command.
1412 #
1413 # Like with_locked_state, this method assumes the keyword-based parameter
1414 # style, and adds a 'state' parameter with the new state.  Also like
1415 # with_locked_state, it replaces the $cbname key with a wrapped version of that
1416 # callback.  It then calls $sub.
1417 sub _with_updated_state {
1418     my $self = shift;
1419     my ($paramsref, $cbname, $sub) = @_;
1420     my %params = %$paramsref;
1421     my $state;
1422
1423     my $steps = define_steps
1424         cb_ref => \$paramsref->{$cbname};
1425
1426     step start => sub {
1427         $self->with_locked_state($self->{'statefile'},
1428             $params{$cbname}, $steps->{'got_lock'});
1429     };
1430
1431     step got_lock => sub {
1432         ($state, my $new_cb) = @_;
1433
1434         # set up params for calling through to $sub later
1435         $params{'state'} = $state;
1436         $params{$cbname} = $new_cb;
1437
1438         if (!keys %$state) {
1439             $state->{'slots'} = {};
1440             $state->{'drives'} = {};
1441             $state->{'drive_lru'} = [];
1442             $state->{'bc2lb'} = {};
1443             $state->{'current_slot'} = -1;
1444         }
1445
1446         # this is for testing ONLY!
1447         $self->{'__last_state'} = $state;
1448
1449         # if it's not time for another run of the status command yet, then just skip to
1450         # the end.
1451         if (defined $state->{'last_status'}
1452             and time < $state->{'last_status'} + $self->{'status_interval'}) {
1453             $self->_debug("too early for another 'status' invocation");
1454             $steps->{'done'}->();
1455         } else {
1456             $steps->{'wait'}->();
1457         }
1458     };
1459
1460     step wait => sub {
1461         $self->_after_delay($state, $steps->{'call_status'});
1462     };
1463
1464     step call_status => sub {
1465         $self->{'interface'}->status($steps->{'status_cb'});
1466     };
1467
1468     step status_cb => sub {
1469         my ($err, $status) = @_;
1470         if ($err) {
1471             return $self->make_error("fatal", $params{$cbname},
1472                 message => $err);
1473         }
1474
1475         $state->{'last_status'} = time;
1476         $self->_debug("updating state");
1477
1478         # process the results; $status can update our slot->label
1479         # mapping, but the barcode->label mapping stays the same.
1480
1481         my $new_slots = {};
1482         my ($drv, $slot, $info);
1483
1484         # note that loaded_in is always undef; it will be set correctly
1485         # when the drives are scanned
1486         while (($slot, $info) = each %{$status->{'slots'}}) {
1487             if ($info->{'empty'}) {
1488                 # empty slot
1489                 $new_slots->{$slot} = {
1490                     state => Amanda::Changer::SLOT_EMPTY,
1491                     device_status => undef,
1492                     f_type => undef,
1493                     label => undef,
1494                     barcode => undef,
1495                     loaded_in => undef,
1496                     ie => $info->{'ie'},
1497                 };
1498                 next;
1499             }
1500
1501             if (defined $info->{'barcode'}) {
1502
1503                 my $label = $state->{'bc2lb'}->{$info->{'barcode'}};
1504
1505                 $new_slots->{$slot} = {
1506                     state => Amanda::Changer::SLOT_FULL,
1507                     device_status => $state->{'slots'}->{$slot}->{device_status},
1508                     f_type => $state->{'slots'}->{$slot}->{f_type},
1509                     label => $label,
1510                     barcode => $info->{'barcode'},
1511                     loaded_in => undef,
1512                     ie => $info->{'ie'},
1513                 };
1514             } else {
1515                 # assume the status of this slot has not changed since the last
1516                 # time we looked at it, although mark it as not loaded in a slot
1517                 if (exists $state->{'slots'}->{$slot}) {
1518                     $new_slots->{$slot} = $state->{'slots'}->{$slot};
1519                     $new_slots->{$slot}->{'loaded_in'} = undef;
1520                 } else {
1521                     $new_slots->{$slot} = {
1522                         state => Amanda::Changer::SLOT_FULL,
1523                         device_status => undef,
1524                         f_type => undef,
1525                         label => undef,
1526                         barcode => undef,
1527                         loaded_in => undef,
1528                         ie => $info->{'ie'},
1529                     };
1530                 }
1531             }
1532         }
1533         my $old_slots_state = $state->{'slots'};
1534         $state->{'slots'} = $new_slots;
1535
1536         # now handle the drives
1537         my $new_drives = {};
1538         while (($drv, $info) = each %{$status->{'drives'}}) {
1539             my $old_drive = $state->{'drives'}->{$drv};
1540
1541             # if this drive still has a valid reservation, don't change it
1542             if (defined $old_drive->{'res_info'}
1543                         and $self->_res_info_verify($old_drive->{'res_info'})) {
1544                 $new_drives->{$drv} = $old_drive;
1545                 next;
1546             }
1547
1548             # if the drive is empty, this is pretty easy
1549             if (!defined $info) {
1550                 $new_drives->{$drv} = {
1551                     state => Amanda::Changer::SLOT_EMPTY,
1552                     label => undef,
1553                     barcode => undef,
1554                     orig_slot => undef,
1555                 };
1556                 next;
1557             }
1558
1559             # trust our own orig_slot over that from the changer, if possible,
1560             # as some changers do not report this information accurately
1561             my ($orig_slot, $label);
1562             if (defined $old_drive->{'orig_slot'}) {
1563                 $orig_slot = $old_drive->{'orig_slot'};
1564                 $label = $old_drive->{'label'};
1565             }
1566
1567             # but don't trust it if the barcode has changed
1568             if (defined $info->{'barcode'}
1569                     and defined $old_drive->{'barcode'}
1570                     and $info->{'barcode'} ne $old_drive->{'barcode'}) {
1571                 $orig_slot = undef;
1572                 $label = undef;
1573             }
1574
1575             # get the robot's notion of the original slot if we don't know ourselves
1576             if (!defined $orig_slot) {
1577                 $orig_slot = $info->{'orig_slot'};
1578             }
1579
1580             # but if there's a tape in that slot, then we've got a problem
1581             if (defined $orig_slot
1582                     and $state->{'slots'}->{$orig_slot}->{'state'} != Amanda::Changer::SLOT_EMPTY) {
1583                 warning("mtx indicates tape in drive $drv should go to slot $orig_slot, " .
1584                         "but that slot is not empty.");
1585                 $orig_slot = undef;
1586                 for my $slot (keys %{ $state->{'slots'} }) {
1587                     if ($state->{'slots'}->{$slot}->{'state'} == Amanda::Changer::SLOT_EMPTY) {
1588                         $orig_slot = $slot;
1589                         last;
1590                     }
1591                 }
1592                 if (!defined $orig_slot) {
1593                     warning("cannot find an empty slot for the tape in drive $drv");
1594                 }
1595             }
1596
1597             # and look up the label by barcode if possible
1598             if (!defined $label && defined $info->{'barcode'}) {
1599                 $label = $state->{'bc2lb'}->{$info->{'barcode'}};
1600             }
1601
1602             $new_drives->{$drv} = {
1603                 state => Amanda::Changer::SLOT_FULL,
1604                 label => $label,
1605                 barcode => $info->{'barcode'},
1606                 orig_slot => $orig_slot,
1607             };
1608         }
1609         $state->{'drives'} = $new_drives;
1610
1611         # update the loaded_in info for the relevant slots
1612         while (($drv, $info) = each %$new_drives) {
1613             # also update the slots with the relevant 'loaded_in' info
1614             if (defined $info->{'orig_slot'}) {
1615                 my $old_state = $old_slots_state->{$info->{'orig_slot'}};
1616                 $state->{'slots'}->{$info->{'orig_slot'}} = {
1617                     state => $info->{'state'},
1618                     device_status => $old_state->{'device_status'},
1619                     f_type => $old_state->{'f_type'},
1620                     label => $info->{'label'},
1621                     barcode => $info->{'barcode'},
1622                     loaded_in => $drv,
1623                 };
1624             }
1625         }
1626
1627         # sanity check that we don't have tape-device info for nonexistent drives
1628         for my $dr (@{$self->{'driveorder'}}) {
1629             if (!exists $state->{'drives'}->{$dr}) {
1630                 warning("tape-device property specified for drive $dr, but no such " .
1631                         "drive exists in the library");
1632             }
1633         }
1634
1635         if ($state->{'current_slot'} == -1) {
1636             $state->{'current_slot'} = $self->_get_next_slot($state, -1);
1637         }
1638
1639         $steps->{'done'}->();
1640     };
1641
1642     step done => sub {
1643         # finally, call through to the user's method; $params{$cbname} has been
1644         # properly patched to release the state lock when this method is done.
1645         $sub->(%params);
1646     };
1647 }
1648
1649 ##
1650 # reservation records
1651
1652 # A reservation record is recorded in the statefile, and is distinct from an
1653 # Amanda::Changer::robot:Reservation object in that it is seen by all users of
1654 # the tape device, whether in this process or another.
1655 #
1656 # This is abstracted out to enable support for a more robust mechanism than
1657 # caching a pid.
1658
1659 sub _res_info_new {
1660     my $self = shift;
1661     return { pid => $$, };
1662 }
1663
1664 sub _res_info_verify {
1665     my $self = shift;
1666     my ($res_info) = @_;
1667
1668     # true if this is our reservation
1669     return 1 if ($res_info->{'pid'} == $$);
1670
1671     # or if the process is dead
1672     return kill 0, $res_info->{'pid'};
1673 }
1674
1675 sub _res_info_is_mine {
1676     my $self = shift;
1677     my ($res_info) = @_;
1678
1679     return 1 if ($res_info and $res_info->{'pid'} == $$);
1680 }
1681
1682 package Amanda::Changer::robot::Reservation;
1683 use vars qw( @ISA );
1684 use Amanda::Debug qw( debug warning );
1685 @ISA = qw( Amanda::Changer::Reservation );
1686
1687 sub new {
1688     my $class = shift;
1689     my ($chg, $slot, $drive, $device, $barcode) = @_;
1690     my $self = Amanda::Changer::Reservation::new($class);
1691
1692     $self->{'chg'} = $chg;
1693
1694     $self->{'drive'} = $drive;
1695     $self->{'device'} = $device;
1696     $self->{'this_slot'} = $slot;
1697     $self->{'barcode'} = $barcode;
1698
1699     return $self;
1700 }
1701
1702 sub do_release {
1703     my $self = shift;
1704     my %params = @_;
1705
1706     # if we're in global cleanup and the changer is already dead,
1707     # then never mind
1708     return unless $self->{'chg'};
1709
1710     # unref the device, for good measure
1711     $self->{'device'} = undef;
1712
1713     # punt this method off to the changer itself, optionally calling
1714     # the unlocked version if we have the 'state' parameter
1715     if (exists $params{'unlocked'} and exists $params{'state'}) {
1716         $self->{'chg'}->_release_unlocked(drive => $self->{'drive'}, %params);
1717     } else {
1718         $self->{'chg'}->_release(drive => $self->{'drive'}, %params);
1719     }
1720 }
1721
1722 sub set_label {
1723     my $self = shift;
1724     my %params = @_;
1725
1726     return unless $self->{'chg'};
1727     $self->{'chg'}->_set_label(drive => $self->{'drive'},
1728                                dev => $self->{device}, %params);
1729 }
1730
1731 package Amanda::Changer::robot::Interface;
1732
1733 # The physical interface to the changer is abstracted out to allow several
1734 # implementations (see chg-ndmp for one of them).  This API is "reasonably
1735 # stable", but is really only known to this changer and its subclasses, so it's
1736 # not documented in POD.  The methods are:
1737 #
1738 #   $iface->inquiry($inquiry_cb);
1739 #
1740 # Inquire as to the relevant information about the changer.  The result is a
1741 # hash table of lowercased key names and values, $info.  The inquiry_cb is
1742 # called as $inquiry_cb->($errmsg, $info).  The resulting strings have quotes
1743 # and whitespace stripped.  Keys include 'vendor id' and 'product id'.
1744 #
1745 #   $iface->status($status_cb)
1746 #
1747 # Get the READ ELEMENT STATUS output for the changer.  The status_cb is called
1748 # as $status_cb->($errmsg, $status).  $status is a hash with keys 'drives' and
1749 # 'slots', each of which is a hash indexed by the element address (note that drive
1750 # element addresses can and usually do overlap with slots.  The values of the slots
1751 # hash are hashes with keys
1752 #  - 'empty' (1 if the slot is empty)
1753 #  - 'barcode' (which may be undef if the changer does not support barcodes)
1754 #  - 'ie' (a boolean indicating whether this is an import/export slot).
1755 # The values of the drives are undef for empty drive, or hashes with keys
1756 #  - 'barcode' (which may be undef if the changer does not support barcodes)
1757 #  - 'orig_slot' (slot from which this volume was taken, if known)
1758 #
1759 #   $iface->load($slot, $drive, $finished_cb)
1760 #
1761 # Load $slot into $drive.  The finished_cb gets a single argument, $error,
1762 # which is only defined if an error occurred.  Note that this does not
1763 # necessarily wait until the load operation is complete (most drives give
1764 # no such indication) (this method also implements unload, if $un=1)
1765 #
1766 #   $iface->unload($drive, $slot, $finished_cb);
1767 #
1768 # Unload $drive into $slot.  Finished_cb is just as for load().
1769 #
1770 #   $iface->eject($drive_name, $finished_cb);
1771 #
1772 # Eject $drive_name (named /dev/whatever, not the drive number), and call finished_cb.
1773 #
1774 #   $iface->transfer($src_slot, $dst_slot, $finished_cb);
1775 #
1776 # Move the tape in $src_slot into $dst_slot.  The finished_cb gets a single
1777 # argument, $error, which is only defined if an error occurred.  Note that this
1778 # does not necessarily wait until the load operation is complete.
1779
1780 package Amanda::Changer::robot::Interface::MTX;
1781
1782 use Amanda::Paths;
1783 use Amanda::Config qw( :getconf );
1784 use Amanda::Debug qw( debug warning );
1785 use Amanda::MainLoop qw( :GIOCondition synchronized make_cb define_steps step );
1786 use Amanda::Device qw( :constants );
1787
1788 sub new {
1789     my $class = shift;
1790     my ($device_name, $mtx, $ignore_barcodes) = @_;
1791
1792     unless (-e $device_name) {
1793         return Amanda::Changer->make_error("fatal", undef,
1794             message => "'$device_name' not found");
1795     }
1796
1797     return bless {
1798         # This object uses a big lock to block *all* operations, not just mtx
1799         # invocations.  This allows us to add delays to certain operations, while still
1800         # holding the lock.
1801         lock => [],
1802         device_name => $device_name,
1803         mtx => $mtx,
1804         ignore_barcodes => $ignore_barcodes,
1805     }, $class;
1806 }
1807
1808 sub inquiry {
1809     my $self = shift;
1810     my ($inquiry_cb) = @_;
1811
1812     synchronized($self->{'lock'}, $inquiry_cb, sub {
1813         my ($inquiry_cb) = @_;
1814         my $sys_cb = make_cb(sys_cb => sub {
1815             my ($exitstatus, $output) = @_;
1816             if ($exitstatus != 0) {
1817                 return $inquiry_cb->("error from mtx: " . $output, {});
1818             } else {
1819                 my %info;
1820                 for my $line (split '\n', $output) {
1821                     if (my ($k, $v) = ($line =~ /^(.*):\s*(.*)$/)) {
1822                         $v =~ s/^'(.*)'$/$1/;
1823                         $v =~ s/\s*$//;
1824                         $info{lc $k} = $v;
1825                     }
1826                 }
1827                 return $inquiry_cb->(undef, \%info);
1828             }
1829
1830         });
1831         $self->_run_system_command($sys_cb,
1832             $self->{'mtx'}, "-f", $self->{'device_name'}, 'inquiry');
1833     });
1834 }
1835
1836 sub status {
1837     my $self = shift;
1838     my ($status_cb) = @_;
1839
1840     synchronized($self->{'lock'}, $status_cb, sub {
1841         my ($status_cb) = @_;
1842
1843         my $sys_cb = make_cb(sys_cb => sub {
1844             my ($exitstatus, $output) = @_;
1845             if ($exitstatus != 0) {
1846                 my $err = $output;
1847                 # if it's a regular SCSI error, just show the sense key
1848                 my ($sensekey) = ($err =~ /mtx: Request Sense: Sense Key=(.*)\n/);
1849                 $err = "SCSI error; Sense Key=$sensekey" if $sensekey;
1850                 return $status_cb->("error from mtx: " . $err, {});
1851             } else {
1852                 my %status;
1853                 for my $line (split '\n', $output) {
1854                     my ($slot, $ie, $slinfo);
1855
1856                     # drives (data transfer elements)
1857                     if (($slot, $slinfo) = ($line =~
1858                                 /^Data Transfer Element\s*(\d+)?\s*:\s*(.*)/i)) {
1859                         # assume 0 when not given a drive #
1860                         $slot = 0 unless defined $slot;
1861                         if ($slinfo =~ /^Empty/i) {
1862                             $status{'drives'}->{$slot} = undef;
1863                         } elsif ($slinfo =~ /^Full/i) {
1864                             my ($barcode, $orig_slot);
1865                             ($barcode) = ($slinfo =~ /:VolumeTag\s*=\s*(\S+)/i);
1866                             ($orig_slot) = ($slinfo =~ /\(Storage Element (\d+) Loaded\)/i);
1867                             $status{'drives'}->{$slot} = {
1868                                 barcode => $barcode,
1869                                 orig_slot => $orig_slot,
1870                             };
1871                         }
1872
1873                     # slots (storage elements)
1874                     } elsif (($slot, $ie, $slinfo) = ($line =~
1875                                 /^\s*Storage Element\s*(\d+)\s*(IMPORT\/EXPORT)?\s*:\s*(.*)/i)) {
1876                         $ie = $ie? 1 : 0;
1877                         if ($slinfo =~ /^Empty/i) {
1878                             $status{'slots'}->{$slot} = {
1879                                 empty => 1,
1880                                 ie => $ie,
1881                             };
1882                         } elsif ($slinfo =~ /^Full/i) {
1883                             my $barcode;
1884                             ($barcode) = ($slinfo =~ /:VolumeTag\s*=\s*(\S+)/i)
1885                                 unless ($self->{'ignore_barcodes'});
1886                             $status{'slots'}->{$slot} = {
1887                                 barcode => $barcode,
1888                                 ie => $ie,
1889                             };
1890                         }
1891                     }
1892                 }
1893
1894                 return $status_cb->(undef, \%status);
1895             }
1896
1897         });
1898         my @nobarcode = ('nobarcode') if $self->{'ignore_barcodes'};
1899         $self->_run_system_command($sys_cb,
1900             $self->{'mtx'}, "-f", $self->{'device_name'}, @nobarcode, 'status');
1901     });
1902 }
1903
1904 sub load {
1905     my $self = shift;
1906     my ($slot, $drive, $finished_cb, $un) = @_;
1907
1908     synchronized($self->{'lock'}, $finished_cb, sub {
1909         my ($finished_cb) = @_;
1910
1911         my $sys_cb = make_cb(sys_cb => sub {
1912             my ($exitstatus, $output) = @_;
1913             if ($exitstatus != 0) {
1914                 return $finished_cb->("error from mtx: " . $output);
1915             } else {
1916                 return $finished_cb->(undef);
1917             }
1918
1919         });
1920
1921         $self->_run_system_command($sys_cb,
1922             $self->{'mtx'}, "-f", $self->{'device_name'},
1923                             $un? 'unload':'load', $slot, $drive);
1924     });
1925 }
1926
1927 sub unload {
1928     my $self = shift;
1929     my ($drive, $slot, $finished_cb) = @_;
1930     return $self->load($slot, $drive, $finished_cb, 1);
1931 }
1932
1933 sub transfer {
1934     my $self = shift;
1935     my ($src_slot, $dst_slot, $finished_cb) = @_;
1936
1937     synchronized($self->{'lock'}, $finished_cb, sub {
1938         my ($finished_cb) = @_;
1939
1940         my $sys_cb = make_cb(sys_cb => sub {
1941             my ($exitstatus, $output) = @_;
1942             if ($exitstatus != 0) {
1943                 return $finished_cb->("error from mtx: " . $output);
1944             } else {
1945                 return $finished_cb->(undef);
1946             }
1947
1948         });
1949         $self->_run_system_command($sys_cb,
1950             $self->{'mtx'}, "-f", $self->{'device_name'},
1951                             'transfer', $src_slot, $dst_slot);
1952     });
1953 }
1954
1955 # Run 'mtx' and capture the output.  Standard output and error
1956 # are lumped together.
1957 #
1958 # @param $sys_cb: called with ($exitstatus, $output)
1959 # @param @args: args to pass to exec()
1960 sub _run_system_command {
1961     my ($self, $sys_cb, @args) = @_;
1962
1963     debug("invoking " . join(" ", @args));
1964
1965     my ($readfd, $writefd) = POSIX::pipe();
1966     if (!defined($writefd)) {
1967         die("Error creating pipe: $!");
1968     }
1969
1970     my $pid = fork();
1971     if (!defined($pid) or $pid < 0) {
1972         die("Can't fork to run changer script: $!");
1973     }
1974
1975     if (!$pid) {
1976         ## child
1977
1978         # get our file-handle house in order
1979         POSIX::close($readfd);
1980         POSIX::dup2($writefd, 1);
1981         POSIX::dup2($writefd, 2);
1982         POSIX::close($writefd);
1983
1984         %ENV = Amanda::Util::safe_env();
1985
1986         { exec { $args[0] } @args; } # braces protect against warning
1987         exit 127;
1988     }
1989
1990     ## parent
1991
1992     # clean up file descriptors from the fork
1993     POSIX::close($writefd);
1994
1995     # the callbacks that follow share these lexical variables
1996     my $child_eof = 0;
1997     my $child_output = '';
1998     my $child_dead = 0;
1999     my $child_exit_status = 0;
2000     my ($fdsrc, $cwsrc);
2001     my $open_sources = 0;
2002
2003     my $steps = define_steps
2004         cb_ref => \$sys_cb;
2005
2006     step setup => sub {
2007         $open_sources++;
2008         Amanda::MainLoop::fd_source($readfd, $G_IO_IN | $G_IO_ERR | $G_IO_HUP)
2009             ->set_callback($steps->{'fd_source_cb'});
2010
2011         $open_sources++;
2012         Amanda::MainLoop::child_watch_source($pid)
2013             ->set_callback($steps->{'child_watch_source_cb'});
2014     };
2015
2016     step immediate => 1,
2017          fd_source_cb => sub {
2018         my ($fdsrc) = @_;
2019         my ($len, $bytes);
2020         $len = POSIX::read($readfd, $bytes, 1024);
2021
2022         # if we got an EOF, shut things down.
2023         if ($len == 0) {
2024             $child_eof = 1;
2025             POSIX::close($readfd);
2026             $fdsrc->remove();
2027             $fdsrc = undef; # break a reference loop
2028             $steps->{'maybe_finished'}->();
2029         } else {
2030             # otherwise, just keep the bytes
2031             $child_output .= $bytes;
2032         }
2033     };
2034
2035     step immediate => 1,
2036          child_watch_source_cb => sub {
2037         my ($cwsrc, $got_pid, $got_status) = @_;
2038         $cwsrc->remove();
2039         $cwsrc = undef; # break a reference loop
2040         $child_dead = 1;
2041         $child_exit_status = $got_status;
2042
2043         $steps->{'maybe_finished'}->();
2044     };
2045
2046     step maybe_finished => sub {
2047         return if --$open_sources;
2048
2049         # everything is finished -- process the results and invoke the callback
2050         chomp $child_output;
2051
2052         # let the callback take care of any further interpretation
2053         my $exitval = POSIX::WEXITSTATUS($child_exit_status);
2054         $sys_cb->($exitval, $child_output);
2055     };
2056 }
2057
2058 1;