]> git.proxmox.com Git - proxmox-backup.git/blob - src/api2/tape/drive.rs
tape: add drive status api
[proxmox-backup.git] / src / api2 / tape / drive.rs
1 use std::path::Path;
2 use std::sync::Arc;
3
4 use anyhow::{bail, format_err, Error};
5 use serde_json::Value;
6
7 use proxmox::{
8 sortable,
9 identity,
10 list_subdirs_api_method,
11 tools::Uuid,
12 sys::error::SysError,
13 api::{
14 api,
15 RpcEnvironment,
16 Router,
17 SubdirMap,
18 },
19 };
20
21 use crate::{
22 config::{
23 self,
24 drive::check_drive_exists,
25 },
26 api2::types::{
27 UPID_SCHEMA,
28 DRIVE_NAME_SCHEMA,
29 MEDIA_LABEL_SCHEMA,
30 MEDIA_POOL_NAME_SCHEMA,
31 Authid,
32 LinuxTapeDrive,
33 ScsiTapeChanger,
34 TapeDeviceInfo,
35 MediaIdFlat,
36 LabelUuidMap,
37 MamAttribute,
38 LinuxDriveStatusFlat,
39 },
40 server::WorkerTask,
41 tape::{
42 TAPE_STATUS_DIR,
43 TapeDriver,
44 MediaChange,
45 Inventory,
46 MediaStateDatabase,
47 MediaId,
48 mtx_load,
49 mtx_unload,
50 linux_tape_device_list,
51 read_mam_attributes,
52 open_drive,
53 media_changer,
54 update_changer_online_status,
55 file_formats::{
56 MediaLabel,
57 MediaSetLabel,
58 },
59 },
60 };
61
62 #[api(
63 input: {
64 properties: {
65 drive: {
66 schema: DRIVE_NAME_SCHEMA,
67 },
68 slot: {
69 description: "Source slot number",
70 minimum: 1,
71 },
72 },
73 },
74 )]
75 /// Load media via changer from slot
76 pub async fn load_slot(
77 drive: String,
78 slot: u64,
79 _param: Value,
80 ) -> Result<(), Error> {
81
82 let (config, _digest) = config::drive::config()?;
83
84 let drive_config: LinuxTapeDrive = config.lookup("linux", &drive)?;
85
86 let changer: ScsiTapeChanger = match drive_config.changer {
87 Some(ref changer) => config.lookup("changer", changer)?,
88 None => bail!("drive '{}' has no associated changer", drive),
89 };
90
91 tokio::task::spawn_blocking(move || {
92 let drivenum = drive_config.changer_drive_id.unwrap_or(0);
93 mtx_load(&changer.path, slot, drivenum)
94 }).await?
95 }
96
97 #[api(
98 input: {
99 properties: {
100 drive: {
101 schema: DRIVE_NAME_SCHEMA,
102 },
103 "changer-id": {
104 schema: MEDIA_LABEL_SCHEMA,
105 },
106 },
107 },
108 )]
109 /// Load media with specified label
110 ///
111 /// Issue a media load request to the associated changer device.
112 pub async fn load_media(drive: String, changer_id: String) -> Result<(), Error> {
113
114 let (config, _digest) = config::drive::config()?;
115
116 tokio::task::spawn_blocking(move || {
117 let (mut changer, _) = media_changer(&config, &drive, false)?;
118 changer.load_media(&changer_id)
119 }).await?
120 }
121
122 #[api(
123 input: {
124 properties: {
125 drive: {
126 schema: DRIVE_NAME_SCHEMA,
127 },
128 slot: {
129 description: "Target slot number. If omitted, defaults to the slot that the drive was loaded from.",
130 minimum: 1,
131 optional: true,
132 },
133 },
134 },
135 )]
136 /// Unload media via changer
137 pub async fn unload(
138 drive: String,
139 slot: Option<u64>,
140 _param: Value,
141 ) -> Result<(), Error> {
142
143 let (config, _digest) = config::drive::config()?;
144
145 let mut drive_config: LinuxTapeDrive = config.lookup("linux", &drive)?;
146
147 let changer: ScsiTapeChanger = match drive_config.changer {
148 Some(ref changer) => config.lookup("changer", changer)?,
149 None => bail!("drive '{}' has no associated changer", drive),
150 };
151
152 let drivenum = drive_config.changer_drive_id.unwrap_or(0);
153
154 tokio::task::spawn_blocking(move || {
155 if let Some(slot) = slot {
156 mtx_unload(&changer.path, slot, drivenum)
157 } else {
158 drive_config.unload_media()
159 }
160 }).await?
161 }
162
163 #[api(
164 input: {
165 properties: {},
166 },
167 returns: {
168 description: "The list of autodetected tape drives.",
169 type: Array,
170 items: {
171 type: TapeDeviceInfo,
172 },
173 },
174 )]
175 /// Scan tape drives
176 pub fn scan_drives(_param: Value) -> Result<Vec<TapeDeviceInfo>, Error> {
177
178 let list = linux_tape_device_list();
179
180 Ok(list)
181 }
182
183 #[api(
184 input: {
185 properties: {
186 drive: {
187 schema: DRIVE_NAME_SCHEMA,
188 },
189 fast: {
190 description: "Use fast erase.",
191 type: bool,
192 optional: true,
193 default: true,
194 },
195 },
196 },
197 returns: {
198 schema: UPID_SCHEMA,
199 },
200 )]
201 /// Erase media
202 pub fn erase_media(
203 drive: String,
204 fast: Option<bool>,
205 rpcenv: &mut dyn RpcEnvironment,
206 ) -> Result<Value, Error> {
207
208 let (config, _digest) = config::drive::config()?;
209
210 check_drive_exists(&config, &drive)?; // early check before starting worker
211
212 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
213
214 let upid_str = WorkerTask::new_thread(
215 "erase-media",
216 Some(drive.clone()),
217 auth_id,
218 true,
219 move |_worker| {
220 let mut drive = open_drive(&config, &drive)?;
221 drive.erase_media(fast.unwrap_or(true))?;
222 Ok(())
223 }
224 )?;
225
226 Ok(upid_str.into())
227 }
228
229 #[api(
230 input: {
231 properties: {
232 drive: {
233 schema: DRIVE_NAME_SCHEMA,
234 },
235 },
236 },
237 returns: {
238 schema: UPID_SCHEMA,
239 },
240 )]
241 /// Rewind tape
242 pub fn rewind(
243 drive: String,
244 rpcenv: &mut dyn RpcEnvironment,
245 ) -> Result<Value, Error> {
246
247 let (config, _digest) = config::drive::config()?;
248
249 check_drive_exists(&config, &drive)?; // early check before starting worker
250
251 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
252
253 let upid_str = WorkerTask::new_thread(
254 "rewind-media",
255 Some(drive.clone()),
256 auth_id,
257 true,
258 move |_worker| {
259 let mut drive = open_drive(&config, &drive)?;
260 drive.rewind()?;
261 Ok(())
262 }
263 )?;
264
265 Ok(upid_str.into())
266 }
267
268 #[api(
269 input: {
270 properties: {
271 drive: {
272 schema: DRIVE_NAME_SCHEMA,
273 },
274 },
275 },
276 )]
277 /// Eject/Unload drive media
278 pub async fn eject_media(drive: String) -> Result<(), Error> {
279
280 let (config, _digest) = config::drive::config()?;
281
282 tokio::task::spawn_blocking(move || {
283 let (mut changer, _) = media_changer(&config, &drive, false)?;
284
285 if !changer.eject_on_unload() {
286 let mut drive = open_drive(&config, &drive)?;
287 drive.eject_media()?;
288 }
289
290 changer.unload_media()
291 }).await?
292 }
293
294 #[api(
295 input: {
296 properties: {
297 drive: {
298 schema: DRIVE_NAME_SCHEMA,
299 },
300 "changer-id": {
301 schema: MEDIA_LABEL_SCHEMA,
302 },
303 pool: {
304 schema: MEDIA_POOL_NAME_SCHEMA,
305 optional: true,
306 },
307 },
308 },
309 returns: {
310 schema: UPID_SCHEMA,
311 },
312 )]
313 /// Label media
314 ///
315 /// Write a new media label to the media in 'drive'. The media is
316 /// assigned to the specified 'pool', or else to the free media pool.
317 ///
318 /// Note: The media need to be empty (you may want to erase it first).
319 pub fn label_media(
320 drive: String,
321 pool: Option<String>,
322 changer_id: String,
323 rpcenv: &mut dyn RpcEnvironment,
324 ) -> Result<Value, Error> {
325
326 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
327
328 if let Some(ref pool) = pool {
329 let (pool_config, _digest) = config::media_pool::config()?;
330
331 if pool_config.sections.get(pool).is_none() {
332 bail!("no such pool ('{}')", pool);
333 }
334 }
335
336 let (config, _digest) = config::drive::config()?;
337
338 let upid_str = WorkerTask::new_thread(
339 "label-media",
340 Some(drive.clone()),
341 auth_id,
342 true,
343 move |worker| {
344
345 let mut drive = open_drive(&config, &drive)?;
346
347 drive.rewind()?;
348
349 match drive.read_next_file() {
350 Ok(Some(_file)) => bail!("media is not empty (erase first)"),
351 Ok(None) => { /* EOF mark at BOT, assume tape is empty */ },
352 Err(err) => {
353 if err.is_errno(nix::errno::Errno::ENOSPC) || err.is_errno(nix::errno::Errno::EIO) {
354 /* assume tape is empty */
355 } else {
356 bail!("media read error - {}", err);
357 }
358 }
359 }
360
361 let ctime = proxmox::tools::time::epoch_i64();
362 let label = MediaLabel {
363 changer_id: changer_id.to_string(),
364 uuid: Uuid::generate(),
365 ctime,
366 };
367
368 write_media_label(worker, &mut drive, label, pool)
369 }
370 )?;
371
372 Ok(upid_str.into())
373 }
374
375 fn write_media_label(
376 worker: Arc<WorkerTask>,
377 drive: &mut Box<dyn TapeDriver>,
378 label: MediaLabel,
379 pool: Option<String>,
380 ) -> Result<(), Error> {
381
382 drive.label_tape(&label)?;
383
384 let mut media_set_label = None;
385
386 if let Some(ref pool) = pool {
387 // assign media to pool by writing special media set label
388 worker.log(format!("Label media '{}' for pool '{}'", label.changer_id, pool));
389 let set = MediaSetLabel::with_data(&pool, [0u8; 16].into(), 0, label.ctime);
390
391 drive.write_media_set_label(&set)?;
392 media_set_label = Some(set);
393 } else {
394 worker.log(format!("Label media '{}' (no pool assignment)", label.changer_id));
395 }
396
397 let media_id = MediaId { label, media_set_label };
398
399 let mut inventory = Inventory::load(Path::new(TAPE_STATUS_DIR))?;
400 inventory.store(media_id.clone())?;
401
402 drive.rewind()?;
403
404 match drive.read_label() {
405 Ok(Some(info)) => {
406 if info.label.uuid != media_id.label.uuid {
407 bail!("verify label failed - got wrong label uuid");
408 }
409 if let Some(ref pool) = pool {
410 match info.media_set_label {
411 Some(set) => {
412 if set.uuid != [0u8; 16].into() {
413 bail!("verify media set label failed - got wrong set uuid");
414 }
415 if &set.pool != pool {
416 bail!("verify media set label failed - got wrong pool");
417 }
418 }
419 None => {
420 bail!("verify media set label failed (missing set label)");
421 }
422 }
423 }
424 },
425 Ok(None) => bail!("verify label failed (got empty media)"),
426 Err(err) => bail!("verify label failed - {}", err),
427 };
428
429 drive.rewind()?;
430
431 Ok(())
432 }
433
434 #[api(
435 input: {
436 properties: {
437 drive: {
438 schema: DRIVE_NAME_SCHEMA,
439 },
440 },
441 },
442 returns: {
443 type: MediaIdFlat,
444 },
445 )]
446 /// Read media label
447 pub async fn read_label(drive: String) -> Result<MediaIdFlat, Error> {
448
449 let (config, _digest) = config::drive::config()?;
450
451 tokio::task::spawn_blocking(move || {
452 let mut drive = open_drive(&config, &drive)?;
453
454 let media_id = drive.read_label()?;
455
456 let media_id = match media_id {
457 Some(media_id) => {
458 let mut flat = MediaIdFlat {
459 uuid: media_id.label.uuid.to_string(),
460 changer_id: media_id.label.changer_id.clone(),
461 ctime: media_id.label.ctime,
462 media_set_ctime: None,
463 media_set_uuid: None,
464 pool: None,
465 seq_nr: None,
466 };
467 if let Some(set) = media_id.media_set_label {
468 flat.pool = Some(set.pool.clone());
469 flat.seq_nr = Some(set.seq_nr);
470 flat.media_set_uuid = Some(set.uuid.to_string());
471 flat.media_set_ctime = Some(set.ctime);
472 }
473 flat
474 }
475 None => {
476 bail!("Media is empty (no label).");
477 }
478 };
479
480 Ok(media_id)
481 }).await?
482 }
483
484 #[api(
485 input: {
486 properties: {
487 drive: {
488 schema: DRIVE_NAME_SCHEMA,
489 },
490 },
491 },
492 returns: {
493 description: "The list of media labels with associated media Uuid (if any).",
494 type: Array,
495 items: {
496 type: LabelUuidMap,
497 },
498 },
499 )]
500 /// List known media labels (Changer Inventory)
501 ///
502 /// Note: Only useful for drives with associated changer device.
503 ///
504 /// This method queries the changer to get a list of media labels.
505 ///
506 /// Note: This updates the media online status.
507 pub async fn inventory(
508 drive: String,
509 ) -> Result<Vec<LabelUuidMap>, Error> {
510
511 let (config, _digest) = config::drive::config()?;
512
513 tokio::task::spawn_blocking(move || {
514 let (changer, changer_name) = media_changer(&config, &drive, false)?;
515
516 let changer_id_list = changer.list_media_changer_ids()?;
517
518 let state_path = Path::new(TAPE_STATUS_DIR);
519
520 let mut inventory = Inventory::load(state_path)?;
521 let mut state_db = MediaStateDatabase::load(state_path)?;
522
523 update_changer_online_status(
524 &config,
525 &mut inventory,
526 &mut state_db,
527 &changer_name,
528 &changer_id_list,
529 )?;
530
531 let mut list = Vec::new();
532
533 for changer_id in changer_id_list.iter() {
534 if changer_id.starts_with("CLN") {
535 // skip cleaning unit
536 continue;
537 }
538
539 let changer_id = changer_id.to_string();
540
541 if let Some(media_id) = inventory.find_media_by_changer_id(&changer_id) {
542 list.push(LabelUuidMap { changer_id, uuid: Some(media_id.label.uuid.to_string()) });
543 } else {
544 list.push(LabelUuidMap { changer_id, uuid: None });
545 }
546 }
547
548 Ok(list)
549 }).await?
550 }
551
552 #[api(
553 input: {
554 properties: {
555 drive: {
556 schema: DRIVE_NAME_SCHEMA,
557 },
558 "read-all-labels": {
559 description: "Load all tapes and try read labels (even if already inventoried)",
560 type: bool,
561 optional: true,
562 },
563 },
564 },
565 returns: {
566 schema: UPID_SCHEMA,
567 },
568 )]
569 /// Update inventory
570 ///
571 /// Note: Only useful for drives with associated changer device.
572 ///
573 /// This method queries the changer to get a list of media labels. It
574 /// then loads any unknown media into the drive, reads the label, and
575 /// store the result to the media database.
576 ///
577 /// Note: This updates the media online status.
578 pub fn update_inventory(
579 drive: String,
580 read_all_labels: Option<bool>,
581 rpcenv: &mut dyn RpcEnvironment,
582 ) -> Result<Value, Error> {
583
584 let (config, _digest) = config::drive::config()?;
585
586 check_drive_exists(&config, &drive)?; // early check before starting worker
587
588 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
589
590 let upid_str = WorkerTask::new_thread(
591 "inventory-update",
592 Some(drive.clone()),
593 auth_id,
594 true,
595 move |worker| {
596
597 let (mut changer, changer_name) = media_changer(&config, &drive, false)?;
598
599 let changer_id_list = changer.list_media_changer_ids()?;
600 if changer_id_list.is_empty() {
601 worker.log(format!("changer device does not list any media labels"));
602 }
603
604 let state_path = Path::new(TAPE_STATUS_DIR);
605
606 let mut inventory = Inventory::load(state_path)?;
607 let mut state_db = MediaStateDatabase::load(state_path)?;
608
609 update_changer_online_status(&config, &mut inventory, &mut state_db, &changer_name, &changer_id_list)?;
610
611 for changer_id in changer_id_list.iter() {
612 if changer_id.starts_with("CLN") {
613 worker.log(format!("skip cleaning unit '{}'", changer_id));
614 continue;
615 }
616
617 let changer_id = changer_id.to_string();
618
619 if !read_all_labels.unwrap_or(false) {
620 if let Some(_) = inventory.find_media_by_changer_id(&changer_id) {
621 worker.log(format!("media '{}' already inventoried", changer_id));
622 continue;
623 }
624 }
625
626 if let Err(err) = changer.load_media(&changer_id) {
627 worker.warn(format!("unable to load media '{}' - {}", changer_id, err));
628 continue;
629 }
630
631 let mut drive = open_drive(&config, &drive)?;
632 match drive.read_label() {
633 Err(err) => {
634 worker.warn(format!("unable to read label form media '{}' - {}", changer_id, err));
635 }
636 Ok(None) => {
637 worker.log(format!("media '{}' is empty", changer_id));
638 }
639 Ok(Some(media_id)) => {
640 if changer_id != media_id.label.changer_id {
641 worker.warn(format!("label changer ID missmatch ({} != {})", changer_id, media_id.label.changer_id));
642 continue;
643 }
644 worker.log(format!("inventorize media '{}' with uuid '{}'", changer_id, media_id.label.uuid));
645 inventory.store(media_id)?;
646 }
647 }
648 }
649 Ok(())
650 }
651 )?;
652
653 Ok(upid_str.into())
654 }
655
656
657 #[api(
658 input: {
659 properties: {
660 drive: {
661 schema: DRIVE_NAME_SCHEMA,
662 },
663 pool: {
664 schema: MEDIA_POOL_NAME_SCHEMA,
665 optional: true,
666 },
667 },
668 },
669 returns: {
670 schema: UPID_SCHEMA,
671 },
672 )]
673 /// Label media with barcodes from changer device
674 pub fn barcode_label_media(
675 drive: String,
676 pool: Option<String>,
677 rpcenv: &mut dyn RpcEnvironment,
678 ) -> Result<Value, Error> {
679
680 if let Some(ref pool) = pool {
681 let (pool_config, _digest) = config::media_pool::config()?;
682
683 if pool_config.sections.get(pool).is_none() {
684 bail!("no such pool ('{}')", pool);
685 }
686 }
687
688 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
689
690 let upid_str = WorkerTask::new_thread(
691 "barcode-label-media",
692 Some(drive.clone()),
693 auth_id,
694 true,
695 move |worker| {
696 barcode_label_media_worker(worker, drive, pool)
697 }
698 )?;
699
700 Ok(upid_str.into())
701 }
702
703 fn barcode_label_media_worker(
704 worker: Arc<WorkerTask>,
705 drive: String,
706 pool: Option<String>,
707 ) -> Result<(), Error> {
708
709 let (config, _digest) = config::drive::config()?;
710
711 let (mut changer, changer_name) = media_changer(&config, &drive, false)?;
712
713 let changer_id_list = changer.list_media_changer_ids()?;
714
715 let state_path = Path::new(TAPE_STATUS_DIR);
716
717 let mut inventory = Inventory::load(state_path)?;
718 let mut state_db = MediaStateDatabase::load(state_path)?;
719
720 update_changer_online_status(&config, &mut inventory, &mut state_db, &changer_name, &changer_id_list)?;
721
722 if changer_id_list.is_empty() {
723 bail!("changer device does not list any media labels");
724 }
725
726 for changer_id in changer_id_list {
727 if changer_id.starts_with("CLN") { continue; }
728
729 inventory.reload()?;
730 if inventory.find_media_by_changer_id(&changer_id).is_some() {
731 worker.log(format!("media '{}' already inventoried (already labeled)", changer_id));
732 continue;
733 }
734
735 worker.log(format!("checking/loading media '{}'", changer_id));
736
737 if let Err(err) = changer.load_media(&changer_id) {
738 worker.warn(format!("unable to load media '{}' - {}", changer_id, err));
739 continue;
740 }
741
742 let mut drive = open_drive(&config, &drive)?;
743 drive.rewind()?;
744
745 match drive.read_next_file() {
746 Ok(Some(_file)) => {
747 worker.log(format!("media '{}' is not empty (erase first)", changer_id));
748 continue;
749 }
750 Ok(None) => { /* EOF mark at BOT, assume tape is empty */ },
751 Err(err) => {
752 if err.is_errno(nix::errno::Errno::ENOSPC) || err.is_errno(nix::errno::Errno::EIO) {
753 /* assume tape is empty */
754 } else {
755 worker.warn(format!("media '{}' read error (maybe not empty - erase first)", changer_id));
756 continue;
757 }
758 }
759 }
760
761 let ctime = proxmox::tools::time::epoch_i64();
762 let label = MediaLabel {
763 changer_id: changer_id.to_string(),
764 uuid: Uuid::generate(),
765 ctime,
766 };
767
768 write_media_label(worker.clone(), &mut drive, label, pool.clone())?
769 }
770
771 Ok(())
772 }
773
774 #[api(
775 input: {
776 properties: {
777 drive: {
778 schema: DRIVE_NAME_SCHEMA,
779 },
780 },
781 },
782 returns: {
783 description: "A List of medium auxiliary memory attributes.",
784 type: Array,
785 items: {
786 type: MamAttribute,
787 },
788 },
789 )]
790 /// Read Cartridge Memory (Medium auxiliary memory attributes)
791 pub fn cartridge_memory(drive: String) -> Result<Vec<MamAttribute>, Error> {
792
793 let (config, _digest) = config::drive::config()?;
794
795 let drive_config: LinuxTapeDrive = config.lookup("linux", &drive)?;
796
797 read_mam_attributes(&drive_config.path)
798 }
799
800 #[api(
801 input: {
802 properties: {
803 drive: {
804 schema: DRIVE_NAME_SCHEMA,
805 },
806 },
807 },
808 returns: {
809 type: LinuxDriveStatusFlat,
810 },
811 )]
812 /// Get drive status
813 pub fn status(drive: String) -> Result<LinuxDriveStatusFlat, Error> {
814
815 let (config, _digest) = config::drive::config()?;
816
817 let drive_config: LinuxTapeDrive = config.lookup("linux", &drive)?;
818
819 let handle = drive_config.open()
820 .map_err(|err| format_err!("open drive '{}' ({}) failed - {}", drive, drive_config.path, err))?;
821
822 let drive_status = handle.get_drive_status()?;
823
824 Ok(drive_status.into())
825 }
826
827 #[sortable]
828 pub const SUBDIRS: SubdirMap = &sorted!([
829 (
830 "barcode-label-media",
831 &Router::new()
832 .put(&API_METHOD_BARCODE_LABEL_MEDIA)
833 ),
834 (
835 "eject-media",
836 &Router::new()
837 .put(&API_METHOD_EJECT_MEDIA)
838 ),
839 (
840 "erase-media",
841 &Router::new()
842 .put(&API_METHOD_ERASE_MEDIA)
843 ),
844 (
845 "inventory",
846 &Router::new()
847 .get(&API_METHOD_INVENTORY)
848 .put(&API_METHOD_UPDATE_INVENTORY)
849 ),
850 (
851 "label-media",
852 &Router::new()
853 .put(&API_METHOD_LABEL_MEDIA)
854 ),
855 (
856 "load-slot",
857 &Router::new()
858 .put(&API_METHOD_LOAD_SLOT)
859 ),
860 (
861 "cartridge-memory",
862 &Router::new()
863 .put(&API_METHOD_CARTRIDGE_MEMORY)
864 ),
865 (
866 "read-label",
867 &Router::new()
868 .get(&API_METHOD_READ_LABEL)
869 ),
870 (
871 "rewind",
872 &Router::new()
873 .put(&API_METHOD_REWIND)
874 ),
875 (
876 "scan",
877 &Router::new()
878 .get(&API_METHOD_SCAN_DRIVES)
879 ),
880 (
881 "status",
882 &Router::new()
883 .get(&API_METHOD_STATUS)
884 ),
885 (
886 "unload",
887 &Router::new()
888 .put(&API_METHOD_UNLOAD)
889 ),
890 ]);
891
892 pub const ROUTER: Router = Router::new()
893 .get(&list_subdirs_api_method!(SUBDIRS))
894 .subdirs(SUBDIRS);