]> git.proxmox.com Git - proxmox-backup.git/blame - src/tape/media_pool.rs
fixup examples
[proxmox-backup.git] / src / tape / media_pool.rs
CommitLineData
c4d8542e
DM
1//! Media Pool
2//!
3//! A set of backup medias.
4//!
5//! This struct manages backup media state during backup. The main
d1d74c43 6//! purpose is to allocate media sets and assign new tapes to it.
c4d8542e
DM
7//!
8//!
9
1057b1f5 10use std::path::{PathBuf, Path};
30316192
DM
11use std::fs::File;
12
c4d8542e
DM
13use anyhow::{bail, Error};
14use ::serde::{Deserialize, Serialize};
15
16use proxmox::tools::Uuid;
17
18use crate::{
8a0046f5 19 backup::Fingerprint,
c4d8542e
DM
20 api2::types::{
21 MediaStatus,
c1c2c8f6 22 MediaLocation,
c4d8542e
DM
23 MediaSetPolicy,
24 RetentionPolicy,
25 MediaPoolConfig,
26 },
27 tools::systemd::time::compute_next_event,
28 tape::{
29 MediaId,
30 MediaSet,
c4d8542e 31 Inventory,
f281b8d3 32 MediaCatalog,
30316192
DM
33 lock_media_set,
34 lock_media_pool,
35 lock_unassigned_media_pool,
c4d8542e 36 file_formats::{
a78348ac 37 MediaLabel,
c4d8542e
DM
38 MediaSetLabel,
39 },
40 }
41};
42
c4d8542e
DM
43/// Media Pool
44pub struct MediaPool {
45
46 name: String,
1057b1f5 47 state_path: PathBuf,
c4d8542e
DM
48
49 media_set_policy: MediaSetPolicy,
50 retention: RetentionPolicy,
cdf39e62
DM
51
52 changer_name: Option<String>,
ab77d660 53 force_media_availability: bool,
cdf39e62 54
30316192
DM
55 // Set this if you do not need to allocate writeable media - this
56 // is useful for list_media()
57 no_media_set_locking: bool,
58
8a0046f5 59 encrypt_fingerprint: Option<Fingerprint>,
c4d8542e
DM
60
61 inventory: Inventory,
c4d8542e
DM
62
63 current_media_set: MediaSet,
30316192 64 current_media_set_lock: Option<File>,
c4d8542e
DM
65}
66
67impl MediaPool {
68
69 /// Creates a new instance
cdf39e62
DM
70 ///
71 /// If you specify a `changer_name`, only media accessible via
72 /// that changer is considered available. If you pass `None` for
73 /// `changer`, all offline media is considered available (backups
74 /// to standalone drives may not use media from inside a tape
75 /// library).
c4d8542e
DM
76 pub fn new(
77 name: &str,
78 state_path: &Path,
79 media_set_policy: MediaSetPolicy,
80 retention: RetentionPolicy,
cdf39e62 81 changer_name: Option<String>,
8a0046f5 82 encrypt_fingerprint: Option<Fingerprint>,
30316192 83 no_media_set_locking: bool, // for list_media()
c4d8542e
DM
84 ) -> Result<Self, Error> {
85
30316192
DM
86 let _pool_lock = if no_media_set_locking {
87 None
88 } else {
89 Some(lock_media_pool(state_path, name)?)
90 };
91
c4d8542e
DM
92 let inventory = Inventory::load(state_path)?;
93
94 let current_media_set = match inventory.latest_media_set(name) {
95 Some(set_uuid) => inventory.compute_media_set_members(&set_uuid)?,
96 None => MediaSet::new(),
97 };
98
30316192
DM
99 let current_media_set_lock = if no_media_set_locking {
100 None
101 } else {
102 Some(lock_media_set(state_path, current_media_set.uuid(), None)?)
103 };
104
c4d8542e
DM
105 Ok(MediaPool {
106 name: String::from(name),
1057b1f5 107 state_path: state_path.to_owned(),
c4d8542e
DM
108 media_set_policy,
109 retention,
cdf39e62 110 changer_name,
c4d8542e 111 inventory,
c4d8542e 112 current_media_set,
30316192 113 current_media_set_lock,
8a0046f5 114 encrypt_fingerprint,
ab77d660 115 force_media_availability: false,
30316192 116 no_media_set_locking,
c4d8542e
DM
117 })
118 }
119
ab77d660
DM
120 /// Pretend all Online(x) and Offline media is available
121 ///
122 /// Only media in Vault(y) is considered unavailable.
123 pub fn force_media_availability(&mut self) {
124 self.force_media_availability = true;
125 }
126
32b75d36
DM
127 /// Returns the the current media set
128 pub fn current_media_set(&self) -> &MediaSet {
129 &self.current_media_set
90e16be3
DM
130 }
131
c4d8542e
DM
132 /// Creates a new instance using the media pool configuration
133 pub fn with_config(
c4d8542e
DM
134 state_path: &Path,
135 config: &MediaPoolConfig,
cdf39e62 136 changer_name: Option<String>,
30316192 137 no_media_set_locking: bool, // for list_media()
c4d8542e
DM
138 ) -> Result<Self, Error> {
139
e062ebbc 140 let allocation = config.allocation.clone().unwrap_or_else(|| String::from("continue")).parse()?;
c4d8542e 141
e062ebbc 142 let retention = config.retention.clone().unwrap_or_else(|| String::from("keep")).parse()?;
c4d8542e 143
8a0046f5
DM
144 let encrypt_fingerprint = match config.encrypt {
145 Some(ref fingerprint) => Some(fingerprint.parse()?),
146 None => None,
147 };
148
149 MediaPool::new(
150 &config.name,
151 state_path,
152 allocation,
153 retention,
cdf39e62 154 changer_name,
8a0046f5 155 encrypt_fingerprint,
30316192 156 no_media_set_locking,
8a0046f5 157 )
c4d8542e
DM
158 }
159
160 /// Returns the pool name
161 pub fn name(&self) -> &str {
162 &self.name
163 }
164
d1d74c43 165 /// Returns encryption settings
8a0046f5
DM
166 pub fn encrypt_fingerprint(&self) -> Option<Fingerprint> {
167 self.encrypt_fingerprint.clone()
168 }
169
25350f33
DM
170 pub fn set_media_status_damaged(&mut self, uuid: &Uuid) -> Result<(), Error> {
171 self.inventory.set_media_status_damaged(uuid)
172 }
8a0046f5 173
c4d8542e
DM
174 fn compute_media_state(&self, media_id: &MediaId) -> (MediaStatus, MediaLocation) {
175
cfae8f06 176 let (status, location) = self.inventory.status_and_location(&media_id.label.uuid);
c4d8542e
DM
177
178 match status {
179 MediaStatus::Full | MediaStatus::Damaged | MediaStatus::Retired => {
180 return (status, location);
181 }
182 MediaStatus::Unknown | MediaStatus::Writable => {
183 /* possibly writable - fall through to check */
184 }
185 }
186
187 let set = match media_id.media_set_label {
188 None => return (MediaStatus::Writable, location), // not assigned to any pool
189 Some(ref set) => set,
190 };
191
192 if set.pool != self.name { // should never trigger
193 return (MediaStatus::Unknown, location); // belong to another pool
194 }
195 if set.uuid.as_ref() == [0u8;16] { // not assigned to any pool
196 return (MediaStatus::Writable, location);
197 }
198
199 if &set.uuid != self.current_media_set.uuid() {
200 return (MediaStatus::Full, location); // assume FULL
201 }
202
203 // media is member of current set
204 if self.current_media_set.is_last_media(&media_id.label.uuid) {
205 (MediaStatus::Writable, location) // last set member is writable
206 } else {
207 (MediaStatus::Full, location)
208 }
209 }
210
211 /// Returns the 'MediaId' with associated state
212 pub fn lookup_media(&self, uuid: &Uuid) -> Result<BackupMedia, Error> {
213 let media_id = match self.inventory.lookup_media(uuid) {
214 None => bail!("unable to lookup media {}", uuid),
215 Some(media_id) => media_id.clone(),
216 };
217
218 if let Some(ref set) = media_id.media_set_label {
219 if set.pool != self.name {
220 bail!("media does not belong to pool ({} != {})", set.pool, self.name);
221 }
222 }
223
224 let (status, location) = self.compute_media_state(&media_id);
225
226 Ok(BackupMedia::with_media_id(
227 media_id,
228 location,
229 status,
230 ))
231 }
232
233 /// List all media associated with this pool
234 pub fn list_media(&self) -> Vec<BackupMedia> {
235 let media_id_list = self.inventory.list_pool_media(&self.name);
236
237 media_id_list.into_iter()
238 .map(|media_id| {
239 let (status, location) = self.compute_media_state(&media_id);
240 BackupMedia::with_media_id(
241 media_id,
242 location,
243 status,
244 )
245 })
246 .collect()
247 }
248
249 /// Set media status to FULL.
250 pub fn set_media_status_full(&mut self, uuid: &Uuid) -> Result<(), Error> {
251 let media = self.lookup_media(uuid)?; // check if media belongs to this pool
252 if media.status() != &MediaStatus::Full {
cfae8f06 253 self.inventory.set_media_status_full(uuid)?;
c4d8542e
DM
254 }
255 Ok(())
256 }
257
258 /// Make sure the current media set is usable for writing
259 ///
260 /// If not, starts a new media set. Also creates a new
e953029e 261 /// set if media_set_policy implies it, or if 'force' is true.
ab77d660
DM
262 ///
263 /// Note: We also call this in list_media to compute correct media
264 /// status, so this must not change persistent/saved state.
90e16be3
DM
265 ///
266 /// Returns the reason why we started a new media set (if we do)
30316192
DM
267 pub fn start_write_session(
268 &mut self,
269 current_time: i64,
e953029e 270 force: bool,
30316192
DM
271 ) -> Result<Option<String>, Error> {
272
273 let _pool_lock = if self.no_media_set_locking {
274 None
275 } else {
276 Some(lock_media_pool(&self.state_path, &self.name)?)
277 };
90e16be3 278
30316192
DM
279 self.inventory.reload()?;
280
e953029e
DC
281 let mut create_new_set = if force {
282 Some(String::from("forced"))
283 } else {
284 match self.current_set_usable() {
285 Err(err) => {
286 Some(err.to_string())
287 }
288 Ok(_) => None,
289 }
c4d8542e
DM
290 };
291
90e16be3 292 if create_new_set.is_none() {
c4d8542e
DM
293 match &self.media_set_policy {
294 MediaSetPolicy::AlwaysCreate => {
90e16be3 295 create_new_set = Some(String::from("policy is AlwaysCreate"));
c4d8542e
DM
296 }
297 MediaSetPolicy::CreateAt(event) => {
298 if let Some(set_start_time) = self.inventory.media_set_start_time(&self.current_media_set.uuid()) {
299 if let Ok(Some(alloc_time)) = compute_next_event(event, set_start_time as i64, false) {
90e16be3
DM
300 if current_time >= alloc_time {
301 create_new_set = Some(String::from("policy CreateAt event triggered"));
c4d8542e
DM
302 }
303 }
304 }
305 }
306 MediaSetPolicy::ContinueCurrent => { /* do nothing here */ }
307 }
308 }
309
90e16be3 310 if create_new_set.is_some() {
c4d8542e 311 let media_set = MediaSet::new();
30316192
DM
312
313 let current_media_set_lock = if self.no_media_set_locking {
314 None
315 } else {
316 Some(lock_media_set(&self.state_path, media_set.uuid(), None)?)
317 };
318
319 self.current_media_set_lock = current_media_set_lock;
c4d8542e
DM
320 self.current_media_set = media_set;
321 }
322
90e16be3 323 Ok(create_new_set)
c4d8542e
DM
324 }
325
326 /// List media in current media set
327 pub fn current_media_list(&self) -> Result<Vec<&Uuid>, Error> {
328 let mut list = Vec::new();
329 for opt_uuid in self.current_media_set.media_list().iter() {
330 match opt_uuid {
331 Some(ref uuid) => list.push(uuid),
332 None => bail!("current_media_list failed - media set is incomplete"),
333 }
334 }
335 Ok(list)
336 }
337
d1d74c43 338 // tests if the media data is considered as expired at specified time
c4d8542e
DM
339 pub fn media_is_expired(&self, media: &BackupMedia, current_time: i64) -> bool {
340 if media.status() != &MediaStatus::Full {
341 return false;
342 }
343
344 let expire_time = self.inventory.media_expire_time(
345 media.id(), &self.media_set_policy, &self.retention);
346
1bed3aed 347 current_time >= expire_time
c4d8542e
DM
348 }
349
8a0046f5
DM
350 // check if a location is considered on site
351 pub fn location_is_available(&self, location: &MediaLocation) -> bool {
352 match location {
cdf39e62 353 MediaLocation::Online(name) => {
ab77d660
DM
354 if self.force_media_availability {
355 true
cdf39e62 356 } else {
ab77d660
DM
357 if let Some(ref changer_name) = self.changer_name {
358 name == changer_name
359 } else {
360 // a standalone drive cannot use media currently inside a library
361 false
362 }
cdf39e62
DM
363 }
364 }
365 MediaLocation::Offline => {
ab77d660
DM
366 if self.force_media_availability {
367 true
368 } else {
369 // consider available for standalone drives
370 self.changer_name.is_none()
371 }
cdf39e62 372 }
8a0046f5
DM
373 MediaLocation::Vault(_) => false,
374 }
375 }
376
377 fn add_media_to_current_set(&mut self, mut media_id: MediaId, current_time: i64) -> Result<(), Error> {
378
30316192
DM
379 if self.current_media_set_lock.is_none() {
380 bail!("add_media_to_current_set: media set is not locked - internal error");
381 }
382
8a0046f5
DM
383 let seq_nr = self.current_media_set.media_list().len() as u64;
384
385 let pool = self.name.clone();
386
387 let encrypt_fingerprint = self.encrypt_fingerprint();
388
389 let set = MediaSetLabel::with_data(
390 &pool,
391 self.current_media_set.uuid().clone(),
392 seq_nr,
393 current_time,
394 encrypt_fingerprint,
395 );
396
397 media_id.media_set_label = Some(set);
398
399 let uuid = media_id.label.uuid.clone();
400
f281b8d3 401 MediaCatalog::overwrite(&self.state_path, &media_id, false)?; // overwite catalog
8a0046f5
DM
402 let clear_media_status = true; // remove Full status
403 self.inventory.store(media_id, clear_media_status)?; // store persistently
404
405 self.current_media_set.add_media(uuid);
406
407 Ok(())
408 }
409
410
c4d8542e
DM
411 /// Allocates a writable media to the current media set
412 pub fn alloc_writable_media(&mut self, current_time: i64) -> Result<Uuid, Error> {
413
30316192
DM
414 if self.current_media_set_lock.is_none() {
415 bail!("alloc_writable_media: media set is not locked - internal error");
416 }
417
c4d8542e
DM
418 let last_is_writable = self.current_set_usable()?;
419
c4d8542e
DM
420 if last_is_writable {
421 let last_uuid = self.current_media_set.last_media_uuid().unwrap();
422 let media = self.lookup_media(last_uuid)?;
423 return Ok(media.uuid().clone());
424 }
425
426 // try to find empty media in pool, add to media set
427
30316192
DM
428 { // limit pool lock scope
429 let _pool_lock = lock_media_pool(&self.state_path, &self.name)?;
c4d8542e 430
30316192 431 self.inventory.reload()?;
8a0046f5 432
30316192
DM
433 let media_list = self.list_media();
434
435 let mut empty_media = Vec::new();
436 let mut used_media = Vec::new();
437
438 for media in media_list.into_iter() {
439 if !self.location_is_available(media.location()) {
440 continue;
441 }
442 // already part of a media set?
443 if media.media_set_label().is_some() {
444 used_media.push(media);
445 } else {
446 // only consider writable empty media
447 if media.status() == &MediaStatus::Writable {
448 empty_media.push(media);
449 }
8a0046f5 450 }
c4d8542e 451 }
c4d8542e 452
30316192
DM
453 // sort empty_media, newest first -> oldest last
454 empty_media.sort_unstable_by(|a, b| {
455 let mut res = b.label().ctime.cmp(&a.label().ctime);
456 if res == std::cmp::Ordering::Equal {
457 res = b.label().label_text.cmp(&a.label().label_text);
458 }
459 res
460 });
461
462 if let Some(media) = empty_media.pop() {
463 // found empty media, add to media set an use it
464 let uuid = media.uuid().clone();
465 self.add_media_to_current_set(media.into_id(), current_time)?;
466 return Ok(uuid);
af762341 467 }
c4d8542e 468
30316192 469 println!("no empty media in pool, try to reuse expired media");
c4d8542e 470
30316192 471 let mut expired_media = Vec::new();
c4d8542e 472
30316192
DM
473 for media in used_media.into_iter() {
474 if let Some(set) = media.media_set_label() {
475 if &set.uuid == self.current_media_set.uuid() {
476 continue;
477 }
478 } else {
c4d8542e
DM
479 continue;
480 }
8a0046f5 481
30316192
DM
482 if self.media_is_expired(&media, current_time) {
483 println!("found expired media on media '{}'", media.label_text());
484 expired_media.push(media);
485 }
c4d8542e 486 }
c4d8542e 487
30316192
DM
488 // sort expired_media, newest first -> oldest last
489 expired_media.sort_unstable_by(|a, b| {
490 let mut res = b.media_set_label().unwrap().ctime.cmp(&a.media_set_label().unwrap().ctime);
491 if res == std::cmp::Ordering::Equal {
492 res = b.label().label_text.cmp(&a.label().label_text);
493 }
494 res
495 });
496
497 while let Some(media) = expired_media.pop() {
498 // check if we can modify the media-set (i.e. skip
499 // media used by a restore job)
500 if let Ok(_media_set_lock) = lock_media_set(
501 &self.state_path,
502 &media.media_set_label().unwrap().uuid,
503 Some(std::time::Duration::new(0, 0)), // do not wait
504 ) {
505 println!("reuse expired media '{}'", media.label_text());
506 let uuid = media.uuid().clone();
507 self.add_media_to_current_set(media.into_id(), current_time)?;
508 return Ok(uuid);
509 }
af762341 510 }
25e464c5 511 }
c4d8542e 512
25e464c5 513 println!("no expired media in pool, try to find unassigned/free media");
c4d8542e 514
25e464c5 515 // try unassigned media
30316192 516 let _lock = lock_unassigned_media_pool(&self.state_path)?;
1057b1f5
DM
517
518 self.inventory.reload()?;
519
25e464c5
DM
520 let mut free_media = Vec::new();
521
522 for media_id in self.inventory.list_unassigned_media() {
523
524 let (status, location) = self.compute_media_state(&media_id);
525 if media_id.media_set_label.is_some() { continue; } // should not happen
526
8a0046f5
DM
527 if !self.location_is_available(&location) {
528 continue;
c4d8542e 529 }
25e464c5
DM
530
531 // only consider writable media
532 if status != MediaStatus::Writable { continue; }
533
534 free_media.push(media_id);
535 }
536
af762341
DM
537 // sort free_media, newest first -> oldest last
538 free_media.sort_unstable_by(|a, b| {
539 let mut res = b.label.ctime.cmp(&a.label.ctime);
540 if res == std::cmp::Ordering::Equal {
541 res = b.label.label_text.cmp(&a.label.label_text);
542 }
543 res
544 });
545
8a0046f5
DM
546 if let Some(media_id) = free_media.pop() {
547 println!("use free media '{}'", media_id.label.label_text);
548 let uuid = media_id.label.uuid.clone();
549 self.add_media_to_current_set(media_id, current_time)?;
550 return Ok(uuid);
c4d8542e 551 }
25e464c5 552
25e464c5 553 bail!("alloc writable media in pool '{}' failed: no usable media found", self.name());
c4d8542e
DM
554 }
555
556 /// check if the current media set is usable for writing
557 ///
558 /// This does several consistency checks, and return if
559 /// the last media in the current set is in writable state.
560 ///
561 /// This return error when the media set must not be used any
562 /// longer because of consistency errors.
563 pub fn current_set_usable(&self) -> Result<bool, Error> {
564
6dd05135
DM
565 let media_list = self.current_media_set.media_list();
566
567 let media_count = media_list.len();
c4d8542e
DM
568 if media_count == 0 {
569 return Ok(false);
570 }
571
572 let set_uuid = self.current_media_set.uuid();
573 let mut last_is_writable = false;
574
6dd05135
DM
575 let mut last_enc: Option<Option<Fingerprint>> = None;
576
577 for (seq, opt_uuid) in media_list.iter().enumerate() {
c4d8542e
DM
578 let uuid = match opt_uuid {
579 None => bail!("media set is incomplete (missing media information)"),
580 Some(uuid) => uuid,
581 };
582 let media = self.lookup_media(uuid)?;
583 match media.media_set_label() {
584 Some(MediaSetLabel { seq_nr, uuid, ..}) if *seq_nr == seq as u64 && uuid == set_uuid => { /* OK */ },
585 Some(MediaSetLabel { seq_nr, uuid, ..}) if uuid == set_uuid => {
586 bail!("media sequence error ({} != {})", *seq_nr, seq);
587 },
588 Some(MediaSetLabel { uuid, ..}) => bail!("media owner error ({} != {}", uuid, set_uuid),
589 None => bail!("media owner error (no owner)"),
590 }
6dd05135
DM
591
592 if let Some(set) = media.media_set_label() { // always true here
593 if set.encryption_key_fingerprint != self.encrypt_fingerprint {
594 bail!("pool encryption key changed");
595 }
596 match last_enc {
597 None => {
598 last_enc = Some(set.encryption_key_fingerprint.clone());
599 }
600 Some(ref last_enc) => {
601 if last_enc != &set.encryption_key_fingerprint {
602 bail!("inconsistent media encryption key");
603 }
604 }
605 }
606 }
607
c4d8542e
DM
608 match media.status() {
609 MediaStatus::Full => { /* OK */ },
610 MediaStatus::Writable if (seq + 1) == media_count => {
b81e37f6
DM
611 let media_location = media.location();
612 if self.location_is_available(media_location) {
613 last_is_writable = true;
614 } else {
615 if let MediaLocation::Vault(vault) = media_location {
c4d8542e
DM
616 bail!("writable media offsite in vault '{}'", vault);
617 }
618 }
619 },
620 _ => bail!("unable to use media set - wrong media status {:?}", media.status()),
621 }
622 }
ab77d660 623
c4d8542e
DM
624 Ok(last_is_writable)
625 }
626
627 /// Generate a human readable name for the media set
628 pub fn generate_media_set_name(
629 &self,
630 media_set_uuid: &Uuid,
631 template: Option<String>,
632 ) -> Result<String, Error> {
633 self.inventory.generate_media_set_name(media_set_uuid, template)
634 }
635
c4d8542e
DM
636}
637
638/// Backup media
639///
640/// Combines 'MediaId' with 'MediaLocation' and 'MediaStatus'
641/// information.
642#[derive(Debug,Serialize,Deserialize,Clone)]
643pub struct BackupMedia {
644 /// Media ID
645 id: MediaId,
646 /// Media location
647 location: MediaLocation,
648 /// Media status
649 status: MediaStatus,
650}
651
652impl BackupMedia {
653
654 /// Creates a new instance
655 pub fn with_media_id(
656 id: MediaId,
657 location: MediaLocation,
658 status: MediaStatus,
659 ) -> Self {
660 Self { id, location, status }
661 }
662
663 /// Returns the media location
664 pub fn location(&self) -> &MediaLocation {
665 &self.location
666 }
667
668 /// Returns the media status
669 pub fn status(&self) -> &MediaStatus {
670 &self.status
671 }
672
673 /// Returns the media uuid
674 pub fn uuid(&self) -> &Uuid {
675 &self.id.label.uuid
676 }
677
678 /// Returns the media set label
6543214d
DM
679 pub fn media_set_label(&self) -> Option<&MediaSetLabel> {
680 self.id.media_set_label.as_ref()
681 }
682
683 /// Returns the media creation time
684 pub fn ctime(&self) -> i64 {
685 self.id.label.ctime
c4d8542e
DM
686 }
687
688 /// Updates the media set label
689 pub fn set_media_set_label(&mut self, set_label: MediaSetLabel) {
690 self.id.media_set_label = Some(set_label);
691 }
54f4ecd4 692
c4d8542e 693 /// Returns the drive label
a78348ac 694 pub fn label(&self) -> &MediaLabel {
c4d8542e
DM
695 &self.id.label
696 }
697
698 /// Returns the media id (drive label + media set label)
699 pub fn id(&self) -> &MediaId {
700 &self.id
701 }
702
8a0046f5
DM
703 /// Returns the media id, consumes self)
704 pub fn into_id(self) -> MediaId {
705 self.id
706 }
707
c4d8542e 708 /// Returns the media label (Barcode)
8446fbca
DM
709 pub fn label_text(&self) -> &str {
710 &self.id.label.label_text
c4d8542e
DM
711 }
712}