]> git.proxmox.com Git - pve-storage.git/blob - PVE/Storage/PBSPlugin.pm
pbs: add scan datastore helper
[pve-storage.git] / PVE / Storage / PBSPlugin.pm
1 package PVE::Storage::PBSPlugin;
2
3 # Plugin to access Proxmox Backup Server
4
5 use strict;
6 use warnings;
7
8 use Fcntl qw(F_GETFD F_SETFD FD_CLOEXEC);
9 use IO::File;
10 use JSON;
11 use POSIX qw(strftime ENOENT);
12
13 use PVE::APIClient::LWP;
14 use PVE::JSONSchema qw(get_standard_option);
15 use PVE::Network;
16 use PVE::Storage::Plugin;
17 use PVE::Tools qw(run_command file_read_firstline trim dir_glob_regex dir_glob_foreach $IPV6RE);
18
19 use base qw(PVE::Storage::Plugin);
20
21 # Configuration
22
23 sub type {
24 return 'pbs';
25 }
26
27 sub plugindata {
28 return {
29 content => [ {backup => 1, none => 1}, { backup => 1 }],
30 };
31 }
32
33 sub properties {
34 return {
35 datastore => {
36 description => "Proxmox Backup Server datastore name.",
37 type => 'string',
38 },
39 # openssl s_client -connect <host>:8007 2>&1 |openssl x509 -fingerprint -sha256
40 fingerprint => get_standard_option('fingerprint-sha256'),
41 'encryption-key' => {
42 description => "Encryption key. Use 'autogen' to generate one automatically without passphrase.",
43 type => 'string',
44 },
45 port => {
46 description => "For non default port.",
47 type => 'integer',
48 minimum => 1,
49 maximum => 65535,
50 default => 8007,
51 },
52 };
53 }
54
55 sub options {
56 return {
57 server => { fixed => 1 },
58 datastore => { fixed => 1 },
59 port => { optional => 1 },
60 nodes => { optional => 1},
61 disable => { optional => 1},
62 content => { optional => 1},
63 username => { optional => 1 },
64 password => { optional => 1 },
65 'encryption-key' => { optional => 1 },
66 maxfiles => { optional => 1 },
67 'prune-backups' => { optional => 1 },
68 fingerprint => { optional => 1 },
69 };
70 }
71
72 # Helpers
73
74 sub pbs_password_file_name {
75 my ($scfg, $storeid) = @_;
76
77 return "/etc/pve/priv/storage/${storeid}.pw";
78 }
79
80 sub pbs_set_password {
81 my ($scfg, $storeid, $password) = @_;
82
83 my $pwfile = pbs_password_file_name($scfg, $storeid);
84 mkdir "/etc/pve/priv/storage";
85
86 PVE::Tools::file_set_contents($pwfile, "$password\n");
87 }
88
89 sub pbs_delete_password {
90 my ($scfg, $storeid) = @_;
91
92 my $pwfile = pbs_password_file_name($scfg, $storeid);
93
94 unlink $pwfile;
95 }
96
97 sub pbs_get_password {
98 my ($scfg, $storeid) = @_;
99
100 my $pwfile = pbs_password_file_name($scfg, $storeid);
101
102 return PVE::Tools::file_read_firstline($pwfile);
103 }
104
105 sub pbs_encryption_key_file_name {
106 my ($scfg, $storeid) = @_;
107
108 return "/etc/pve/priv/storage/${storeid}.enc";
109 }
110
111 sub pbs_set_encryption_key {
112 my ($scfg, $storeid, $key) = @_;
113
114 my $pwfile = pbs_encryption_key_file_name($scfg, $storeid);
115 mkdir "/etc/pve/priv/storage";
116
117 PVE::Tools::file_set_contents($pwfile, "$key\n");
118 }
119
120 sub pbs_delete_encryption_key {
121 my ($scfg, $storeid) = @_;
122
123 my $pwfile = pbs_encryption_key_file_name($scfg, $storeid);
124
125 if (!unlink $pwfile) {
126 return if $! == ENOENT;
127 die "failed to delete encryption key! $!\n";
128 }
129 delete $scfg->{'encryption-key'};
130 }
131
132 sub pbs_get_encryption_key {
133 my ($scfg, $storeid) = @_;
134
135 my $pwfile = pbs_encryption_key_file_name($scfg, $storeid);
136
137 return PVE::Tools::file_get_contents($pwfile);
138 }
139
140 # Returns a file handle if there is an encryption key, or `undef` if there is not. Dies on error.
141 sub pbs_open_encryption_key {
142 my ($scfg, $storeid) = @_;
143
144 my $encryption_key_file = pbs_encryption_key_file_name($scfg, $storeid);
145
146 my $keyfd;
147 if (!open($keyfd, '<', $encryption_key_file)) {
148 return undef if $! == ENOENT;
149 die "failed to open encryption key: $encryption_key_file: $!\n";
150 }
151
152 return $keyfd;
153 }
154
155 sub print_volid {
156 my ($storeid, $btype, $bid, $btime) = @_;
157
158 my $time_str = strftime("%FT%TZ", gmtime($btime));
159 my $volname = "backup/${btype}/${bid}/${time_str}";
160
161 return "${storeid}:${volname}";
162 }
163
164 my sub get_server_with_port {
165 my ($scfg) = @_;
166
167 my $server = $scfg->{server};
168 $server = "[$server]" if $server =~ /^$IPV6RE$/;
169
170 if (my $port = $scfg->{port}) {
171 $server .= ":$port" if $port != 8007;
172 }
173 return $server;
174 }
175
176 my $USE_CRYPT_PARAMS = {
177 backup => 1,
178 restore => 1,
179 'upload-log' => 1,
180 };
181
182 my sub do_raw_client_cmd {
183 my ($scfg, $storeid, $client_cmd, $param, %opts) = @_;
184
185 my $use_crypto = $USE_CRYPT_PARAMS->{$client_cmd};
186
187 my $client_exe = '/usr/bin/proxmox-backup-client';
188 die "executable not found '$client_exe'! Proxmox backup client not installed?\n"
189 if ! -x $client_exe;
190
191 my $server = get_server_with_port($scfg);
192 my $datastore = $scfg->{datastore};
193 my $username = $scfg->{username} // 'root@pam';
194
195 my $userns_cmd = delete $opts{userns_cmd};
196
197 my $cmd = [];
198
199 push @$cmd, @$userns_cmd if defined($userns_cmd);
200
201 push @$cmd, $client_exe, $client_cmd;
202
203 # This must live in the top scope to not get closed before the `run_command`
204 my $keyfd;
205 if ($use_crypto) {
206 if (defined($keyfd = pbs_open_encryption_key($scfg, $storeid))) {
207 my $flags = fcntl($keyfd, F_GETFD, 0)
208 // die "failed to get file descriptor flags: $!\n";
209 fcntl($keyfd, F_SETFD, $flags & ~FD_CLOEXEC)
210 or die "failed to remove FD_CLOEXEC from encryption key file descriptor\n";
211 push @$cmd, '--crypt-mode=encrypt', '--keyfd='.fileno($keyfd);
212 } else {
213 push @$cmd, '--crypt-mode=none';
214 }
215 }
216
217 push @$cmd, @$param if defined($param);
218
219 push @$cmd, "--repository", "$username\@$server:$datastore";
220
221 local $ENV{PBS_PASSWORD} = pbs_get_password($scfg, $storeid);
222
223 local $ENV{PBS_FINGERPRINT} = $scfg->{fingerprint};
224
225 # no ascii-art on task logs
226 local $ENV{PROXMOX_OUTPUT_NO_BORDER} = 1;
227 local $ENV{PROXMOX_OUTPUT_NO_HEADER} = 1;
228
229 if (my $logfunc = $opts{logfunc}) {
230 $logfunc->("run: " . join(' ', @$cmd));
231 }
232
233 run_command($cmd, %opts);
234 }
235
236 # FIXME: External perl code should NOT have access to this.
237 #
238 # There should be separate functions to
239 # - make backups
240 # - restore backups
241 # - restore files
242 # with a sane API
243 sub run_raw_client_cmd {
244 my ($scfg, $storeid, $client_cmd, $param, %opts) = @_;
245 return do_raw_client_cmd($scfg, $storeid, $client_cmd, $param, %opts);
246 }
247
248 sub run_client_cmd {
249 my ($scfg, $storeid, $client_cmd, $param, $no_output) = @_;
250
251 my $json_str = '';
252 my $outfunc = sub { $json_str .= "$_[0]\n" };
253
254 $param = [] if !defined($param);
255 $param = [ $param ] if !ref($param);
256
257 $param = [@$param, '--output-format=json'] if !$no_output;
258
259 do_raw_client_cmd($scfg, $storeid, $client_cmd, $param,
260 outfunc => $outfunc, errmsg => 'proxmox-backup-client failed');
261
262 return undef if $no_output;
263
264 my $res = decode_json($json_str);
265
266 return $res;
267 }
268
269 # Storage implementation
270
271 sub extract_vzdump_config {
272 my ($class, $scfg, $volname, $storeid) = @_;
273
274 my ($vtype, $name, $vmid, undef, undef, undef, $format) = $class->parse_volname($volname);
275
276 my $config = '';
277 my $outfunc = sub { $config .= "$_[0]\n" };
278
279 my $config_name;
280 if ($format eq 'pbs-vm') {
281 $config_name = 'qemu-server.conf';
282 } elsif ($format eq 'pbs-ct') {
283 $config_name = 'pct.conf';
284 } else {
285 die "unable to extract configuration for backup format '$format'\n";
286 }
287
288 do_raw_client_cmd($scfg, $storeid, 'restore', [ $name, $config_name, '-' ],
289 outfunc => $outfunc, errmsg => 'proxmox-backup-client failed');
290
291 return $config;
292 }
293
294 sub prune_backups {
295 my ($class, $scfg, $storeid, $keep, $vmid, $type, $dryrun, $logfunc) = @_;
296
297 $logfunc //= sub { print "$_[1]\n" };
298
299 my $backups = $class->list_volumes($storeid, $scfg, $vmid, ['backup']);
300
301 $type = 'vm' if defined($type) && $type eq 'qemu';
302 $type = 'ct' if defined($type) && $type eq 'lxc';
303
304 my $backup_groups = {};
305 foreach my $backup (@{$backups}) {
306 (my $backup_type = $backup->{format}) =~ s/^pbs-//;
307
308 next if defined($type) && $backup_type ne $type;
309
310 my $backup_group = "$backup_type/$backup->{vmid}";
311 $backup_groups->{$backup_group} = 1;
312 }
313
314 my @param;
315
316 my $keep_all = delete $keep->{'keep-all'};
317
318 if (!$keep_all) {
319 foreach my $opt (keys %{$keep}) {
320 next if $keep->{$opt} == 0;
321 push @param, "--$opt";
322 push @param, "$keep->{$opt}";
323 }
324 } else { # no need to pass anything to PBS
325 $keep = { 'keep-all' => 1 };
326 }
327
328 push @param, '--dry-run' if $dryrun;
329
330 my $prune_list = [];
331 my $failed;
332
333 foreach my $backup_group (keys %{$backup_groups}) {
334 $logfunc->('info', "running 'proxmox-backup-client prune' for '$backup_group'")
335 if !$dryrun;
336 eval {
337 my $res = run_client_cmd($scfg, $storeid, 'prune', [ $backup_group, @param ]);
338
339 foreach my $backup (@{$res}) {
340 die "result from proxmox-backup-client is not as expected\n"
341 if !defined($backup->{'backup-time'})
342 || !defined($backup->{'backup-type'})
343 || !defined($backup->{'backup-id'})
344 || !defined($backup->{'keep'});
345
346 my $ctime = $backup->{'backup-time'};
347 my $type = $backup->{'backup-type'};
348 my $vmid = $backup->{'backup-id'};
349 my $volid = print_volid($storeid, $type, $vmid, $ctime);
350
351 push @{$prune_list}, {
352 ctime => $ctime,
353 mark => $backup->{keep} ? 'keep' : 'remove',
354 type => $type eq 'vm' ? 'qemu' : 'lxc',
355 vmid => $vmid,
356 volid => $volid,
357 };
358 }
359 };
360 if (my $err = $@) {
361 $logfunc->('err', "prune '$backup_group': $err\n");
362 $failed = 1;
363 }
364 }
365 die "error pruning backups - check log\n" if $failed;
366
367 return $prune_list;
368 }
369
370 my $autogen_encryption_key = sub {
371 my ($scfg, $storeid) = @_;
372 my $encfile = pbs_encryption_key_file_name($scfg, $storeid);
373 if (-f $encfile) {
374 rename $encfile, "$encfile.old";
375 }
376 my $cmd = ['proxmox-backup-client', 'key', 'create', '--kdf', 'none', $encfile];
377 run_command($cmd, errmsg => 'failed to create encryption key');
378 return PVE::Tools::file_get_contents($encfile);
379 };
380
381 sub on_add_hook {
382 my ($class, $storeid, $scfg, %param) = @_;
383
384 my $res = {};
385
386 if (defined(my $password = $param{password})) {
387 pbs_set_password($scfg, $storeid, $password);
388 } else {
389 pbs_delete_password($scfg, $storeid);
390 }
391
392 if (defined(my $encryption_key = $param{'encryption-key'})) {
393 my $decoded_key;
394 if ($encryption_key eq 'autogen') {
395 $res->{'encryption-key'} = $autogen_encryption_key->($scfg, $storeid);
396 $decoded_key = decode_json($res->{'encryption-key'});
397 } else {
398 $decoded_key = eval { decode_json($encryption_key) };
399 if ($@ || !exists($decoded_key->{data})) {
400 die "Value does not seems like a valid, JSON formatted encryption key!\n";
401 }
402 pbs_set_encryption_key($scfg, $storeid, $encryption_key);
403 $res->{'encryption-key'} = $encryption_key;
404 }
405 $scfg->{'encryption-key'} = $decoded_key->{fingerprint} || 1;
406 } else {
407 pbs_delete_encryption_key($scfg, $storeid);
408 }
409
410 return $res;
411 }
412
413 sub on_update_hook {
414 my ($class, $storeid, $scfg, %param) = @_;
415
416 my $res = {};
417
418 if (exists($param{password})) {
419 if (defined($param{password})) {
420 pbs_set_password($scfg, $storeid, $param{password});
421 } else {
422 pbs_delete_password($scfg, $storeid);
423 }
424 }
425
426 if (exists($param{'encryption-key'})) {
427 if (defined(my $encryption_key = delete($param{'encryption-key'}))) {
428 my $decoded_key;
429 if ($encryption_key eq 'autogen') {
430 $res->{'encryption-key'} = $autogen_encryption_key->($scfg, $storeid);
431 $decoded_key = decode_json($res->{'encryption-key'});
432 } else {
433 $decoded_key = eval { decode_json($encryption_key) };
434 if ($@ || !exists($decoded_key->{data})) {
435 die "Value does not seems like a valid, JSON formatted encryption key!\n";
436 }
437 pbs_set_encryption_key($scfg, $storeid, $encryption_key);
438 $res->{'encryption-key'} = $encryption_key;
439 }
440 $scfg->{'encryption-key'} = $decoded_key->{fingerprint} || 1;
441 } else {
442 pbs_delete_encryption_key($scfg, $storeid);
443 }
444 }
445
446 return $res;
447 }
448
449 sub on_delete_hook {
450 my ($class, $storeid, $scfg) = @_;
451
452 pbs_delete_password($scfg, $storeid);
453 pbs_delete_encryption_key($scfg, $storeid);
454
455 return;
456 }
457
458 sub parse_volname {
459 my ($class, $volname) = @_;
460
461 if ($volname =~ m!^backup/([^\s_]+)/([^\s_]+)/([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)$!) {
462 my $btype = $1;
463 my $bid = $2;
464 my $btime = $3;
465 my $format = "pbs-$btype";
466
467 my $name = "$btype/$bid/$btime";
468
469 if ($bid =~ m/^\d+$/) {
470 return ('backup', $name, $bid, undef, undef, undef, $format);
471 } else {
472 return ('backup', $name, undef, undef, undef, undef, $format);
473 }
474 }
475
476 die "unable to parse PBS volume name '$volname'\n";
477 }
478
479 sub path {
480 my ($class, $scfg, $volname, $storeid, $snapname) = @_;
481
482 die "volume snapshot is not possible on pbs storage"
483 if defined($snapname);
484
485 my ($vtype, $name, $vmid) = $class->parse_volname($volname);
486
487 my $server = get_server_with_port($scfg);
488 my $datastore = $scfg->{datastore};
489 my $username = $scfg->{username} // 'root@pam';
490
491 # artifical url - we currently do not use that anywhere
492 my $path = "pbs://$username\@$server:$datastore/$name";
493
494 return ($path, $vmid, $vtype);
495 }
496
497 sub create_base {
498 my ($class, $storeid, $scfg, $volname) = @_;
499
500 die "can't create base images in pbs storage\n";
501 }
502
503 sub clone_image {
504 my ($class, $scfg, $storeid, $volname, $vmid, $snap) = @_;
505
506 die "can't clone images in pbs storage\n";
507 }
508
509 sub alloc_image {
510 my ($class, $storeid, $scfg, $vmid, $fmt, $name, $size) = @_;
511
512 die "can't allocate space in pbs storage\n";
513 }
514
515 sub free_image {
516 my ($class, $storeid, $scfg, $volname, $isBase) = @_;
517
518 my ($vtype, $name, $vmid) = $class->parse_volname($volname);
519
520 run_client_cmd($scfg, $storeid, "forget", [ $name ], 1);
521 }
522
523
524 sub list_images {
525 my ($class, $storeid, $scfg, $vmid, $vollist, $cache) = @_;
526
527 my $res = [];
528
529 return $res;
530 }
531
532 my sub snapshot_files_encrypted {
533 my ($files) = @_;
534 return 0 if !$files;
535
536 my $any;
537 my $all = 1;
538 for my $file (@$files) {
539 my $fn = $file->{filename};
540 next if $fn eq 'client.log.blob' || $fn eq 'index.json.blob';
541
542 my $crypt = $file->{'crypt-mode'};
543
544 $all = 0 if !$crypt || $crypt ne 'encrypt';
545 $any ||= $crypt eq 'encrypt';
546 }
547 return $any && $all;
548 }
549
550 sub list_volumes {
551 my ($class, $storeid, $scfg, $vmid, $content_types) = @_;
552
553 my $res = [];
554
555 return $res if !grep { $_ eq 'backup' } @$content_types;
556
557 my $data = run_client_cmd($scfg, $storeid, "snapshots");
558
559 foreach my $item (@$data) {
560 my $btype = $item->{"backup-type"};
561 my $bid = $item->{"backup-id"};
562 my $epoch = $item->{"backup-time"};
563 my $size = $item->{size} // 1;
564
565 next if !($btype eq 'vm' || $btype eq 'ct');
566 next if $bid !~ m/^\d+$/;
567 next if defined($vmid) && $bid ne $vmid;
568
569 my $volid = print_volid($storeid, $btype, $bid, $epoch);
570
571 my $info = {
572 volid => $volid,
573 format => "pbs-$btype",
574 size => $size,
575 content => 'backup',
576 vmid => int($bid),
577 ctime => $epoch,
578 };
579
580 $info->{verification} = $item->{verification} if defined($item->{verification});
581 $info->{notes} = $item->{comment} if defined($item->{comment});
582 if (defined($item->{fingerprint})) {
583 $info->{encrypted} = $item->{fingerprint};
584 } elsif (snapshot_files_encrypted($item->{files})) {
585 $info->{encrypted} = '1';
586 }
587
588 push @$res, $info;
589 }
590
591 return $res;
592 }
593
594 sub status {
595 my ($class, $storeid, $scfg, $cache) = @_;
596
597 my $total = 0;
598 my $free = 0;
599 my $used = 0;
600 my $active = 0;
601
602 eval {
603 my $res = run_client_cmd($scfg, $storeid, "status");
604
605 $active = 1;
606 $total = $res->{total};
607 $used = $res->{used};
608 $free = $res->{avail};
609 };
610 if (my $err = $@) {
611 warn $err;
612 }
613
614 return ($total, $free, $used, $active);
615 }
616
617 # TODO: use a client with native rust/proxmox-backup bindings to profit from
618 # API schema checks and types
619 my sub pbs_api_connect {
620 my ($scfg, $password) = @_;
621
622 my $params = {};
623
624 my $user = $scfg->{username} // 'root@pam';
625
626 if (my $tokenid = PVE::AccessControl::pve_verify_tokenid($user, 1)) {
627 $params->{apitoken} = "PBSAPIToken=${tokenid}=${password}";
628 } else {
629 $params->{password} = $password;
630 $params->{username} = $user;
631 }
632
633 if (my $fp = $scfg->{fingerprint}) {
634 $params->{cached_fingerprints}->{uc($fp)} = 1;
635 }
636
637 my $conn = PVE::APIClient::LWP->new(
638 %$params,
639 host => $scfg->{server},
640 port => $scfg->{port} // 8007,
641 timeout => 7, # cope with a 401 (3s api delay) and high latency
642 cookie_name => 'PBSAuthCookie',
643 );
644
645 return $conn;
646 }
647
648 # can also be used for not (yet) added storages, pass $scfg with
649 # {
650 # server
651 # user
652 # port (optional default to 8007)
653 # fingerprint (optional for trusted certs)
654 # }
655 sub scan_datastores {
656 my ($scfg, $password) = @_;
657
658 my $conn = pbs_api_connect($scfg, $password);
659
660 my $response = eval { $conn->get('/api2/json/admin/datastore', {}) };
661 die "error fetching datastores - $@" if $@;
662
663 return $response;
664 }
665
666 sub activate_storage {
667 my ($class, $storeid, $scfg, $cache) = @_;
668
669 # a 'status' client command is to expensive here
670 # TODO: use a dummy ping API call to ensure the PBS API daemon is available for real
671 my $server = $scfg->{server};
672 my $port = $scfg->{port} // 8007;
673 PVE::Network::tcp_ping($server, $port, 2);
674
675 return 1;
676 }
677
678 sub deactivate_storage {
679 my ($class, $storeid, $scfg, $cache) = @_;
680 return 1;
681 }
682
683 sub activate_volume {
684 my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
685
686 die "volume snapshot is not possible on pbs device" if $snapname;
687
688 return 1;
689 }
690
691 sub deactivate_volume {
692 my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
693
694 die "volume snapshot is not possible on pbs device" if $snapname;
695
696 return 1;
697 }
698
699 sub get_volume_notes {
700 my ($class, $scfg, $storeid, $volname, $timeout) = @_;
701
702 my (undef, $name, undef, undef, undef, undef, $format) = $class->parse_volname($volname);
703
704 my $data = run_client_cmd($scfg, $storeid, "snapshot", [ "notes", "show", $name ]);
705
706 return $data->{notes};
707 }
708
709 sub update_volume_notes {
710 my ($class, $scfg, $storeid, $volname, $notes, $timeout) = @_;
711
712 my (undef, $name, undef, undef, undef, undef, $format) = $class->parse_volname($volname);
713
714 run_client_cmd($scfg, $storeid, "snapshot", [ "notes", "update", $name, $notes ], 1);
715
716 return undef;
717 }
718
719 sub volume_size_info {
720 my ($class, $scfg, $storeid, $volname, $timeout) = @_;
721
722 my ($vtype, $name, undef, undef, undef, undef, $format) = $class->parse_volname($volname);
723
724 my $data = run_client_cmd($scfg, $storeid, "files", [ $name ]);
725
726 my $size = 0;
727 foreach my $info (@$data) {
728 $size += $info->{size} if $info->{size};
729 }
730
731 my $used = $size;
732
733 return wantarray ? ($size, $format, $used, undef) : $size;
734 }
735
736 sub volume_resize {
737 my ($class, $scfg, $storeid, $volname, $size, $running) = @_;
738 die "volume resize is not possible on pbs device";
739 }
740
741 sub volume_snapshot {
742 my ($class, $scfg, $storeid, $volname, $snap) = @_;
743 die "volume snapshot is not possible on pbs device";
744 }
745
746 sub volume_snapshot_rollback {
747 my ($class, $scfg, $storeid, $volname, $snap) = @_;
748 die "volume snapshot rollback is not possible on pbs device";
749 }
750
751 sub volume_snapshot_delete {
752 my ($class, $scfg, $storeid, $volname, $snap) = @_;
753 die "volume snapshot delete is not possible on pbs device";
754 }
755
756 sub volume_has_feature {
757 my ($class, $scfg, $feature, $storeid, $volname, $snapname, $running) = @_;
758
759 return undef;
760 }
761
762 1;