]>
Commit | Line | Data |
---|---|---|
0bc3e510 WL |
1 | #!/usr/bin/perl |
2 | ||
3 | my $PROGNAME = "pve-zsync"; | |
4 | my $CONFIG_PATH = '/var/lib/'.$PROGNAME.'/'; | |
5 | my $CONFIG = "$PROGNAME.cfg"; | |
6 | my $CRONJOBS = '/etc/cron.d/'.$PROGNAME; | |
7 | my $VMCONFIG = '/var/lib/'.$PROGNAME.'/'; | |
8 | my $PATH = "/usr/sbin/"; | |
9 | my $QEMU_CONF = '/etc/pve/local/qemu-server/'; | |
10 | my $DEBUG = 0; | |
11 | ||
12 | use strict; | |
13 | use warnings; | |
14 | use Data::Dumper qw(Dumper); | |
15 | use Fcntl qw(:flock SEEK_END); | |
16 | use Getopt::Long; | |
17 | use Switch; | |
18 | ||
19 | check_bin ('cstream'); | |
20 | check_bin ('zfs'); | |
21 | check_bin ('ssh'); | |
22 | check_bin ('scp'); | |
23 | ||
24 | sub check_bin { | |
25 | my ($bin) = @_; | |
26 | ||
27 | foreach my $p (split (/:/, $ENV{PATH})) { | |
28 | my $fn = "$p/$bin"; | |
29 | if (-x $fn) { | |
30 | return $fn; | |
31 | } | |
32 | } | |
33 | ||
34 | warn "unable to find command '$bin'\n"; | |
35 | } | |
36 | ||
37 | sub cut_to_width { | |
38 | my ($text, $max) = @_; | |
39 | ||
40 | return $text if (length($text) <= $max); | |
41 | my @spl = split('/', $text); | |
42 | ||
43 | my $count = length($spl[@spl-1]); | |
44 | return "..\/".substr($spl[@spl-1],($count-$max)+3 ,$count) if $count > $max; | |
45 | ||
46 | $count += length($spl[0]) if @spl > 1; | |
47 | return substr($spl[0], 0, $max-4-length($spl[@spl-1]))."\/..\/".$spl[@spl-1] if $count > $max; | |
48 | ||
49 | my $rest = 1 ; | |
50 | $rest = $max-$count if ($max-$count > 0); | |
51 | ||
52 | return "$spl[0]".substr($text, length($spl[0]), $rest)."..\/".$spl[@spl-1]; | |
53 | } | |
54 | ||
55 | sub lock { | |
56 | my ($fh) = @_; | |
57 | flock($fh, LOCK_EX) or die "Cannot lock config - $!\n"; | |
58 | ||
59 | seek($fh, 0, SEEK_END) or die "Cannot seek - $!\n"; | |
60 | } | |
61 | ||
62 | sub unlock { | |
63 | my ($fh) = @_; | |
64 | flock($fh, LOCK_UN) or die "Cannot unlock config- $!\n"; | |
65 | } | |
66 | ||
67 | sub check_config { | |
68 | my ($source, $name, $cfg) = @_; | |
69 | ||
70 | if ($source->{vmid} && $cfg->{$source->{vmid}}->{$name}->{locked}){ | |
71 | return "active" if $cfg->{$source->{vmid}}->{$name}->{locked} eq 'yes'; | |
72 | return "exist" if $cfg->{$source->{vmid}}->{$name}->{locked} eq 'no'; | |
73 | } elsif ($cfg->{$source->{abs_path}}->{$name}->{locked}) { | |
74 | return "active" if $cfg->{$source->{abs_path}}->{$name}->{locked} eq 'yes'; | |
75 | return "exist" if $cfg->{$source->{abs_path}}->{$name}->{locked} eq 'no'; | |
76 | } | |
77 | ||
78 | return undef; | |
79 | } | |
80 | ||
81 | ||
82 | sub check_pool_exsits { | |
83 | my ($pool, $ip) = @_; | |
84 | ||
85 | my $cmd = ''; | |
86 | $cmd = "ssh root\@$ip " if $ip; | |
87 | $cmd .= "zfs list $pool -H"; | |
88 | eval { | |
89 | run_cmd($cmd); | |
90 | }; | |
91 | ||
92 | if($@){ | |
93 | return 1; | |
94 | } | |
95 | return undef; | |
96 | } | |
97 | ||
98 | sub write_to_config { | |
99 | my ($cfg) = @_; | |
100 | ||
101 | open(my $fh, ">", "$CONFIG_PATH$CONFIG") | |
102 | or die "cannot open >$CONFIG_PATH$CONFIG: $!\n"; | |
103 | ||
104 | my $text = decode_config($cfg); | |
105 | ||
106 | print($fh $text); | |
107 | ||
108 | close($fh); | |
109 | } | |
110 | ||
111 | sub read_from_config { | |
112 | ||
113 | unless(-e "$CONFIG_PATH$CONFIG") { | |
114 | return undef; | |
115 | } | |
116 | ||
117 | open(my $fh, "<", "$CONFIG_PATH$CONFIG") | |
118 | or die "cannot open > $CONFIG_PATH$CONFIG: $!\n"; | |
119 | ||
120 | $/ = undef; | |
121 | ||
122 | my $text = <$fh>; | |
123 | ||
124 | unlock($fh); | |
125 | ||
1a7871e7 WL |
126 | close($fh); |
127 | ||
0bc3e510 WL |
128 | my $cfg = encode_config($text); |
129 | ||
130 | return $cfg; | |
131 | } | |
132 | ||
133 | sub decode_config { | |
134 | my ($cfg) = @_; | |
135 | my $raw = ''; | |
ec89fa6d WL |
136 | foreach my $source (sort keys%{$cfg}){ |
137 | foreach my $sync_name (sort keys%{$cfg->{$source}}){ | |
0bc3e510 | 138 | $raw .= "$source: $sync_name\n"; |
ec89fa6d | 139 | foreach my $parameter (sort keys%{$cfg->{$source}->{$sync_name}}){ |
0bc3e510 WL |
140 | $raw .= "\t$parameter: $cfg->{$source}->{$sync_name}->{$parameter}\n"; |
141 | } | |
142 | } | |
143 | } | |
144 | return $raw; | |
145 | } | |
146 | ||
147 | sub encode_config { | |
148 | my ($raw) = @_; | |
149 | my $cfg = {}; | |
150 | my $source; | |
151 | my $check = 0; | |
152 | my $sync_name; | |
153 | ||
154 | while ($raw && $raw =~ s/^(.*?)(\n|$)//) { | |
155 | my $line = $1; | |
156 | ||
157 | next if $line =~ m/^\#/; | |
158 | next if $line =~ m/^\s*$/; | |
159 | ||
160 | if ($line =~ m/^(\t| )(\w+): (.+)/){ | |
161 | my $par = $2; | |
162 | my $value = $3; | |
163 | ||
164 | if ($par eq 'source_pool') { | |
165 | $cfg->{$source}->{$sync_name}->{$par} = $value; | |
166 | die "error in Config: SourcePool value doubled\n" if ($check & 1); | |
167 | $check += 1; | |
168 | } elsif ($par eq 'source_ip') { | |
169 | $cfg->{$source}->{$sync_name}->{$par} = $value; | |
170 | die "error in Config: SourceIP value doubled\n" if ($check & 2); | |
171 | $check += 2; | |
172 | } elsif ($par eq 'locked') { | |
173 | $cfg->{$source}->{$sync_name}->{$par} = $value; | |
174 | die "error in Config: Locked value doubled\n" if ($check & 4); | |
175 | $check += 4; | |
176 | } elsif ($par eq 'method') { | |
177 | $cfg -> {$source}->{$sync_name}->{$par} = $value; | |
178 | die "error in Config: Method value doubled\n" if ($check & 8); | |
179 | $check += 8; | |
180 | } elsif ($par eq 'interval') { | |
181 | $cfg -> {$source}->{$sync_name}->{$par} = $value; | |
182 | die "error in Config: Iterval value doubled\n" if ($check & 16); | |
183 | $check += 16; | |
184 | } elsif ($par eq 'limit') { | |
185 | $cfg -> {$source}->{$sync_name}->{$par} = $value; | |
186 | die "error in Config: Limit value doubled\n" if ($check & 32); | |
187 | $check += 32; | |
188 | } elsif ($par eq 'dest_pool') { | |
189 | $cfg -> {$source}->{$sync_name}->{$par} = $value; | |
190 | die "error in Config: DestPool value doubled\n" if ($check & 64); | |
191 | $check += 64; | |
192 | } elsif ($par eq 'dest_ip') { | |
193 | $cfg -> {$source}->{$sync_name}->{$par} = $value; | |
194 | die "error in Config: DestIp value doubled\n" if ($check & 128); | |
195 | $check += 128; | |
196 | } elsif ($par eq 'dest_path') { | |
197 | $cfg -> {$source}->{$sync_name}->{$par} = $value; | |
198 | die "error in Config: DestPath value doubled\n" if ($check & 256); | |
199 | $check += 256; | |
200 | } elsif ($par eq 'source_path') { | |
201 | $cfg -> {$source}->{$sync_name}->{$par} = $value; | |
202 | die "error in Config: SourcePath value doubled\n" if ($check & 512); | |
203 | $check += 512; | |
204 | } elsif ($par eq 'vmid') { | |
205 | $cfg -> {$source}->{$sync_name}->{$par} = $value; | |
206 | die "error in Config: Vmid value doubled\n" if ($check & 1024); | |
207 | $check += 1024; | |
208 | } elsif ($par =~ 'lsync') { | |
209 | $cfg->{$source}->{$sync_name}->{$par} = $value; | |
210 | die "error in Config: lsync value doubled\n" if ($check & 2048); | |
211 | $check += 2048; | |
212 | } elsif ($par =~ 'maxsnap') { | |
213 | $cfg->{$source}->{$sync_name}->{$par} = $value; | |
214 | die "error in Config: maxsnap value doubled\n" if ($check & 4096); | |
215 | $check += 4096; | |
216 | } else { | |
217 | die "error in Config\n"; | |
218 | } | |
219 | } elsif ($line =~ m/^((\d+.\d+.\d+.\d+):)?([\w\-\_\/]+): (.+){0,1}/){ | |
220 | $source = $3; | |
221 | $sync_name = $4 ? $4 : 'default' ; | |
222 | $cfg->{$source}->{$sync_name} = undef; | |
223 | $cfg->{$source}->{$sync_name}->{source_ip} = $2 if $2; | |
224 | $check = 0; | |
225 | } | |
226 | } | |
227 | return $cfg; | |
228 | } | |
229 | ||
230 | sub parse_target { | |
231 | my ($text) = @_; | |
232 | ||
233 | if ($text =~ m/^((\d+.\d+.\d+.\d+):)?((\w+)\/?)([\w\/\-\_]*)?$/) { | |
234 | ||
235 | die "Input not valid\n" if !$3; | |
236 | my $tmp = $3; | |
237 | my $target = {}; | |
238 | ||
239 | if ($2) { | |
240 | $target->{ip} = $2 ; | |
241 | } | |
242 | ||
243 | if ($tmp =~ m/^(\d\d\d+)$/){ | |
244 | $target->{vmid} = $tmp; | |
245 | } else { | |
246 | $target->{pool} = $4; | |
247 | my $abs_path = $4; | |
248 | if ($5) { | |
249 | $target->{path} = "\/$5"; | |
250 | $abs_path .= "\/$5"; | |
251 | } | |
252 | $target->{abs_path} = $abs_path; | |
253 | } | |
254 | ||
255 | return $target; | |
256 | } | |
257 | die "Input not valid\n"; | |
258 | } | |
259 | ||
260 | sub list { | |
261 | ||
262 | my $cfg = read_from_config("$CONFIG_PATH$CONFIG"); | |
263 | ||
264 | my $list = sprintf("%-25s%-15s%-7s%-20s%-10s%-5s\n" , "SOURCE", "NAME", "ACTIVE", "LAST SYNC", "INTERVAL", "TYPE"); | |
ec89fa6d WL |
265 | |
266 | foreach my $source (sort keys%{$cfg}){ | |
267 | foreach my $sync_name (sort keys%{$cfg->{$source}}){ | |
0bc3e510 WL |
268 | my $source_name = $source; |
269 | $source_name = $cfg->{$source}->{$sync_name}->{source_ip}.":".$source if $cfg->{$source}->{$sync_name}->{source_ip}; | |
270 | $list .= sprintf("%-25s%-15s", cut_to_width($source_name,25), cut_to_width($sync_name,15)); | |
271 | $list .= sprintf("%-7s",$cfg->{$source}->{$sync_name}->{locked}); | |
272 | $list .= sprintf("%-20s",$cfg->{$source}->{$sync_name}->{lsync}); | |
273 | $list .= sprintf("%-10s",$cfg->{$source}->{$sync_name}->{interval}); | |
274 | $list .= sprintf("%-5s\n",$cfg->{$source}->{$sync_name}->{method}); | |
275 | } | |
276 | } | |
277 | ||
278 | return $list; | |
279 | } | |
280 | ||
281 | sub vm_exists { | |
282 | my ($target) = @_; | |
283 | ||
284 | my $cmd = ""; | |
285 | $cmd = "ssh root\@$target->{ip} " if ($target->{ip}); | |
286 | $cmd .= "qm status $target->{vmid}"; | |
287 | ||
288 | my $res = run_cmd($cmd); | |
289 | ||
290 | return 1 if ($res =~ m/^status.*$/); | |
291 | return undef; | |
292 | } | |
293 | ||
294 | sub init { | |
295 | my ($param) = @_; | |
296 | ||
297 | my $cfg = read_from_config; | |
298 | ||
299 | my $vm = {}; | |
300 | ||
301 | my $name = $param->{name} ? $param->{name} : "default"; | |
302 | my $interval = $param->{interval} ? $param->{interval} : 15; | |
303 | ||
304 | my $source = parse_target($param->{source}); | |
305 | my $dest = parse_target($param->{dest}); | |
306 | ||
307 | $vm->{$name}->{dest_pool} = $dest->{pool}; | |
308 | $vm->{$name}->{dest_ip} = $dest->{ip} if $dest->{ip}; | |
309 | $vm->{$name}->{dest_path} = $dest->{path} if $dest->{path}; | |
310 | ||
311 | $param->{method} = "local" if !$dest->{ip} && !$source->{ip}; | |
312 | $vm->{$name}->{locked} = "no"; | |
313 | $vm->{$name}->{interval} = $interval; | |
314 | $vm->{$name}->{method} = $param->{method} ? $param->{method} : "ssh"; | |
315 | $vm->{$name}->{limit} = $param->{limit} if $param->{limit}; | |
316 | $vm->{$name}->{maxsnap} = $param->{maxsnap} if $param->{maxsnap}; | |
317 | ||
318 | if ( my $ip = $vm->{$name}->{dest_ip} ) { | |
319 | run_cmd("ssh-copy-id -i /root/.ssh/id_rsa.pub root\@$ip"); | |
320 | } | |
321 | ||
322 | if ( my $ip = $source->{ip} ) { | |
323 | run_cmd("ssh-copy-id -i /root/.ssh/id_rsa.pub root\@$ip"); | |
324 | } | |
325 | ||
326 | die "Pool $dest->{abs_path} does not exists\n" if check_pool_exsits($dest->{abs_path}, $dest->{ip}); | |
327 | ||
328 | my $check = check_pool_exsits($source->{abs_path}, $source->{ip}) if !$source->{vmid} && $source->{abs_path}; | |
329 | ||
330 | die "Pool $source->{abs_path} does not exists\n" if undef($check); | |
8362418a | 331 | |
0bc3e510 WL |
332 | my $add_job = sub { |
333 | my ($vm, $name) = @_; | |
334 | my $source = ""; | |
335 | ||
336 | if ($vm->{$name}->{vmid}) { | |
337 | $source = "$vm->{$name}->{source_ip}:" if $vm->{$name}->{source_ip}; | |
338 | $source .= $vm->{$name}->{vmid}; | |
339 | } else { | |
340 | $source = $vm->{$name}->{source_pool}; | |
341 | $source .= $vm->{$name}->{source_path} if $vm->{$name}->{source_path}; | |
342 | } | |
343 | die "Config already exists\n" if $cfg->{$source}->{$name}; | |
344 | ||
345 | cron_add($vm); | |
346 | ||
347 | $cfg->{$source}->{$name} = $vm->{$name}; | |
348 | ||
349 | write_to_config($cfg); | |
350 | }; | |
351 | ||
352 | if ($source->{vmid}) { | |
353 | die "VM $source->{vmid} doesn't exist\n" if !vm_exists($source); | |
354 | my $disks = get_disks($source); | |
355 | $vm->{$name}->{vmid} = $source->{vmid}; | |
356 | $vm->{$name}->{lsync} = 0; | |
357 | $vm->{$name}->{source_ip} = $source->{ip} if $source->{ip}; | |
358 | ||
359 | &$add_job($vm, $name); | |
360 | ||
361 | } else { | |
362 | $vm->{$name}->{source_pool} = $source->{pool}; | |
363 | $vm->{$name}->{source_ip} = $source->{ip} if $source->{ip}; | |
364 | $vm->{$name}->{source_path} = $source->{path} if $source->{path}; | |
365 | $vm->{$name}->{lsync} = 0; | |
366 | ||
367 | &$add_job($vm, $name); | |
368 | } | |
369 | ||
1a7871e7 WL |
370 | eval {sync($param) if !$param->{skip};}; |
371 | if(my $err = $@) { | |
372 | destroy($param); | |
373 | print $err; | |
374 | } | |
0bc3e510 WL |
375 | } |
376 | ||
377 | sub destroy { | |
378 | my ($param) = @_; | |
379 | ||
380 | my $cfg = read_from_config("$CONFIG_PATH$CONFIG"); | |
381 | my $name = $param->{name} ? $param->{name} : "default"; | |
382 | ||
383 | my $source = parse_target($param->{source}); | |
384 | ||
385 | my $delete_cron = sub { | |
386 | my ($path, $name, $cfg) = @_; | |
387 | ||
388 | die "Source does not exist!\n" unless $cfg->{$path} ; | |
389 | ||
390 | die "Sync Name does not exist!\n" unless $cfg->{$path}->{$name}; | |
391 | ||
392 | delete $cfg->{$path}->{$name}; | |
393 | ||
394 | delete $cfg->{$path} if keys%{$cfg->{$path}} == 0; | |
395 | ||
396 | write_to_config($cfg); | |
397 | ||
398 | cron_del($path, $name); | |
399 | }; | |
400 | ||
401 | ||
402 | if ($source->{vmid}) { | |
403 | my $path = $source->{vmid}; | |
404 | ||
405 | &$delete_cron($path, $name, $cfg) | |
406 | ||
407 | } else { | |
408 | ||
409 | my $path = $source->{pool}; | |
410 | $path .= $source->{path} if $source->{path}; | |
411 | ||
412 | &$delete_cron($path, $name, $cfg); | |
413 | } | |
414 | } | |
415 | ||
416 | sub sync { | |
417 | my ($param) = @_; | |
418 | ||
419 | my $cfg = read_from_config("$CONFIG_PATH$CONFIG"); | |
420 | ||
421 | my $name = $param->{name} ? $param->{name} : "default"; | |
422 | my $max_snap = $param->{maxsnap} ? $param->{maxsnap} : 1; | |
423 | my $method = $param->{method} ? $param->{method} : "ssh"; | |
424 | ||
425 | my $dest = parse_target($param->{dest}); | |
426 | my $source = parse_target($param->{source}); | |
427 | ||
428 | my $sync_path = sub { | |
429 | my ($source, $name, $cfg, $max_snap, $dest, $method) = @_; | |
430 | ||
431 | ($source->{old_snap},$source->{last_snap}) = snapshot_get($source, $dest, $max_snap, $name); | |
432 | ||
433 | my $job_status = check_config($source, $name, $cfg) if $cfg; | |
434 | die "VM syncing at the moment!\n" if ($job_status && $job_status eq "active"); | |
435 | ||
436 | if ($job_status && $job_status eq "exist") { | |
437 | my $conf_name = $source->{abs_path}; | |
438 | $conf_name = $source->{vmid} if $source->{vmid}; | |
439 | $cfg->{$conf_name}->{$name}->{locked} = "yes"; | |
440 | write_to_config($cfg); | |
441 | } | |
442 | ||
443 | my $date = snapshot_add($source, $dest, $name); | |
444 | ||
445 | send_image($source, $dest, $method, $param->{verbose}, $param->{limit}); | |
446 | ||
447 | snapshot_destroy($source, $dest, $method, $source->{old_snap}) if ($source->{destroy} && $source->{old_snap}); | |
448 | ||
1a7871e7 | 449 | |
0bc3e510 WL |
450 | if ($job_status && $job_status eq "exist") { |
451 | my $conf_name = $source->{abs_path}; | |
452 | $conf_name = $source->{vmid} if $source->{vmid}; | |
453 | $cfg->{$conf_name}->{$name}->{locked} = "no"; | |
454 | $cfg->{$conf_name}->{$name}->{lsync} = $date; | |
455 | write_to_config($cfg); | |
456 | } | |
457 | }; | |
458 | ||
459 | $param->{method} = "ssh" if !$param->{method}; | |
460 | ||
461 | if ($source->{vmid}) { | |
462 | die "VM $source->{vmid} doesn't exist\n" if !vm_exists($source); | |
463 | my $disks = get_disks($source); | |
464 | ||
465 | foreach my $disk (keys %{$disks}) { | |
466 | $source->{abs_path} = $disks->{$disk}->{pool}; | |
467 | $source->{abs_path} .= "\/$disks->{$disk}->{path}" if $disks->{$disk}->{path}; | |
468 | ||
469 | $source->{pool} = $disks->{$disk}->{pool}; | |
470 | $source->{path} = "\/$disks->{$disk}->{path}"; | |
471 | ||
472 | &$sync_path($source, $name, $cfg, $max_snap, $dest, $method); | |
473 | } | |
474 | } else { | |
475 | &$sync_path($source, $name, $cfg, $max_snap, $dest, $method); | |
476 | } | |
477 | } | |
478 | ||
479 | sub snapshot_get{ | |
480 | my ($source, $dest, $max_snap, $name) = @_; | |
481 | ||
482 | my $cmd = "zfs list -r -t snapshot -Ho name, -S creation "; | |
483 | ||
484 | $cmd .= $source->{abs_path}; | |
485 | $cmd = "ssh root\@$source->{ip} ".$cmd if $source->{ip}; | |
486 | ||
487 | my $raw = run_cmd($cmd); | |
488 | my $index = 1; | |
489 | my $line = ""; | |
490 | my $last_snap = undef; | |
491 | ||
492 | while ($raw && $raw =~ s/^(.*?)(\n|$)//) { | |
493 | $line = $1; | |
494 | $last_snap = $line if $index == 1; | |
495 | if ($index == $max_snap) { | |
496 | $source->{destroy} = 1; | |
497 | last; | |
498 | }; | |
499 | $index++; | |
500 | } | |
501 | ||
502 | $line =~ m/^(.+)\@(rep_$name\_.+)(\n|$)/; | |
503 | return ($2, $last_snap) if $2; | |
504 | ||
505 | return undef; | |
506 | } | |
507 | ||
508 | sub snapshot_add { | |
509 | my ($source, $dest, $name) = @_; | |
510 | ||
511 | my $date = get_date(); | |
512 | ||
513 | my $snap_name = "rep_$name\_".$date; | |
514 | ||
515 | $source->{new_snap} = $snap_name; | |
516 | ||
517 | my $path = $source->{abs_path}."\@".$snap_name; | |
518 | ||
519 | my $cmd = "zfs snapshot $path"; | |
520 | $cmd = "ssh root\@$source->{ip} ".$cmd if $source->{ip}; | |
521 | ||
522 | eval{ | |
523 | run_cmd($cmd); | |
524 | }; | |
525 | ||
526 | if (my $err = $@){ | |
527 | snapshot_destroy($source, $dest, 'ssh', $snap_name); | |
528 | die "$err\n"; | |
529 | } | |
530 | return $date; | |
531 | } | |
532 | ||
533 | sub cron_add { | |
534 | my ($vm) = @_; | |
535 | ||
536 | open(my $fh, '>>', "$CRONJOBS") | |
537 | or die "Could not open file: $!\n"; | |
538 | ||
539 | foreach my $name (keys%{$vm}){ | |
540 | my $text = "*/$vm->{$name}->{interval} * * * * root "; | |
541 | $text .= "$PATH$PROGNAME sync"; | |
542 | $text .= " -source "; | |
543 | if ($vm->{$name}->{vmid}) { | |
544 | $text .= "$vm->{$name}->{source_ip}:" if $vm->{$name}->{source_ip}; | |
545 | $text .= "$vm->{$name}->{vmid} "; | |
546 | } else { | |
547 | $text .= "$vm->{$name}->{source_ip}:" if $vm->{$name}->{source_ip}; | |
548 | $text .= "$vm->{$name}->{source_pool}"; | |
549 | $text .= "$vm->{$name}->{source_path}" if $vm->{$name}->{source_path}; | |
550 | } | |
551 | $text .= " -dest "; | |
552 | $text .= "$vm->{$name}->{dest_ip}:" if $vm->{$name}->{dest_ip}; | |
553 | $text .= "$vm->{$name}->{dest_pool}"; | |
554 | $text .= "$vm->{$name}->{dest_path}" if $vm->{$name}->{dest_path}; | |
555 | $text .= " -name $name "; | |
556 | $text .= " -limit $vm->{$name}->{limit}" if $vm->{$name}->{limit}; | |
557 | $text .= " -maxsnap $vm->{$name}->{maxsnap}" if $vm->{$name}->{maxsnap}; | |
558 | $text .= "\n"; | |
559 | print($fh $text); | |
560 | } | |
561 | close($fh); | |
562 | } | |
563 | ||
564 | sub cron_del { | |
565 | my ($source, $name) = @_; | |
566 | ||
567 | open(my $fh, '<', "$CRONJOBS") | |
568 | or die "Could not open file: $!\n"; | |
569 | ||
570 | $/ = undef; | |
571 | ||
572 | my $text = <$fh>; | |
573 | my $buffer = ""; | |
574 | close($fh); | |
575 | while ($text && $text =~ s/^(.*?)(\n|$)//) { | |
576 | my $line = $1.$2; | |
1a7871e7 | 577 | if ($line !~ m/.*$PROGNAME.*$source.*$name.*/){ |
0bc3e510 WL |
578 | $buffer .= $line; |
579 | } | |
580 | } | |
581 | open($fh, '>', "$CRONJOBS") | |
582 | or die "Could not open file: $!\n"; | |
583 | print($fh $buffer); | |
584 | close($fh); | |
585 | } | |
586 | ||
587 | sub get_disks { | |
588 | my ($target) = @_; | |
589 | ||
590 | my $cmd = ""; | |
591 | $cmd = "ssh root\@$target->{ip} " if $target->{ip}; | |
592 | $cmd .= "qm config $target->{vmid}"; | |
593 | ||
594 | my $res = run_cmd($cmd); | |
595 | ||
596 | my $disks = parse_disks($res, $target->{ip}); | |
597 | ||
598 | return $disks; | |
599 | } | |
600 | ||
601 | sub run_cmd { | |
602 | my ($cmd) = @_; | |
603 | print "Start CMD\n" if $DEBUG; | |
604 | print Dumper $cmd if $DEBUG; | |
605 | my $output = `$cmd 2>&1`; | |
606 | ||
607 | die $output if 0 != $?; | |
608 | ||
609 | chomp($output); | |
610 | print Dumper $output if $DEBUG; | |
611 | print "END CMD\n" if $DEBUG; | |
612 | return $output; | |
613 | } | |
614 | ||
615 | sub parse_disks { | |
616 | my ($text, $ip) = @_; | |
617 | ||
618 | my $disks; | |
619 | ||
620 | my $num = 0; | |
621 | my $cmd = ""; | |
622 | $cmd .= "ssh root\@$ip " if $ip; | |
623 | $cmd .= "pvesm zfsscan"; | |
624 | my $zfs_pools = run_cmd($cmd); | |
625 | while ($text && $text =~ s/^(.*?)(\n|$)//) { | |
626 | my $line = $1; | |
627 | my $disk = undef; | |
628 | my $stor = undef; | |
629 | if($line =~ m/^(virtio\d+: )(.+:)([A-Za-z0-9\-]+),(.*)$/) { | |
630 | $disk = $3; | |
631 | $stor = $2; | |
632 | } elsif($line =~ m/^(ide\d+: )(.+:)([A-Za-z0-9\-]+),(.*)$/) { | |
633 | $disk = $3; | |
634 | $stor = $2; | |
635 | } elsif($line =~ m/^(scsi\d+: )(.+:)([A-Za-z0-9\-]+),(.*)$/) { | |
636 | $disk = $3; | |
637 | $stor = $2; | |
638 | } elsif($line =~ m/^(sata\d+: )(.+:)([A-Za-z0-9\-]+),(.*)$/) { | |
639 | $disk = $3; | |
640 | $stor = $2; | |
641 | } | |
642 | ||
643 | if($disk && $disk ne "none" && $disk !~ m/cdrom/ ) { | |
644 | my $cmd = ""; | |
645 | $cmd .= "ssh root\@$ip " if $ip; | |
646 | $cmd .= "pvesm path $stor$disk"; | |
647 | my $path = run_cmd($cmd); | |
648 | ||
649 | if ($path =~ m/^\/dev\/zvol\/(\w+).*(\/$disk)$/){ | |
650 | ||
651 | $disks->{$num}->{pool} = $1; | |
652 | $disks->{$num}->{path} = $disk; | |
653 | $num++; | |
654 | ||
655 | } else { | |
656 | die "ERROR: in path\n"; | |
657 | } | |
658 | } | |
659 | } | |
660 | die "disk is not on ZFS Storage\n" if $num == 0; | |
661 | return $disks; | |
662 | } | |
663 | ||
664 | sub snapshot_destroy { | |
665 | my ($source, $dest, $method, $snap) = @_; | |
666 | ||
667 | my $zfscmd = "zfs destroy "; | |
668 | my $name = "$source->{path}\@$snap"; | |
669 | ||
670 | eval { | |
671 | if($source->{ip} && $method eq 'ssh'){ | |
672 | run_cmd("ssh root\@$source->{ip} $zfscmd $source->{pool}$name"); | |
673 | } else { | |
674 | run_cmd("$zfscmd $source->{pool}$name"); | |
675 | } | |
676 | }; | |
677 | if (my $erro = $@) { | |
678 | warn "WARN: $erro"; | |
679 | } | |
680 | if ($dest){ | |
681 | my $ssh = $dest->{ip} ? "ssh root\@$dest->{ip}" : ""; | |
682 | ||
683 | my $path = ""; | |
684 | $path ="$dest->{path}" if $dest->{path}; | |
685 | ||
686 | my @dir = split(/\//, $source->{path}); | |
687 | eval { | |
688 | run_cmd("$ssh $zfscmd $dest->{pool}$path\/$dir[@dir-1]\@$snap "); | |
689 | }; | |
690 | if (my $erro = $@) { | |
691 | warn "WARN: $erro"; | |
692 | } | |
693 | } | |
694 | } | |
695 | ||
696 | sub snapshot_exist { | |
697 | my ($source ,$dest, $method) = @_; | |
698 | ||
699 | my $cmd = ""; | |
700 | $cmd = "ssh root\@$dest->{ip} " if $dest->{ip}; | |
701 | $cmd .= "zfs list -rt snapshot -Ho name $dest->{pool}"; | |
702 | $cmd .= "$dest->{path}" if $dest->{path}; | |
703 | my @dir = split(/\//, $source->{path}); | |
704 | $cmd .= "\/$dir[@dir-1]\@$source->{old_snap}"; | |
705 | ||
706 | my $text = ""; | |
707 | eval {$text =run_cmd($cmd);}; | |
708 | if (my $erro = $@) { | |
709 | warn "WARN: $erro"; | |
710 | return undef; | |
711 | } | |
712 | ||
713 | while ($text && $text =~ s/^(.*?)(\n|$)//) { | |
714 | my $line = $1; | |
715 | return 1 if $line =~ m/^.*$source->{old_snap}$/; | |
716 | } | |
717 | } | |
718 | ||
719 | sub send_image { | |
720 | my ($source, $dest, $method, $verbose, $limit) = @_; | |
721 | ||
722 | my $cmd = ""; | |
723 | ||
724 | $cmd .= "ssh root\@$source->{ip} " if $source->{ip}; | |
725 | $cmd .= "zfs send "; | |
726 | $cmd .= "-v " if $verbose; | |
727 | ||
728 | if($source->{last_snap} && snapshot_exist($source ,$dest, $method)) { | |
729 | $cmd .= "-i $source->{abs_path}\@$source->{old_snap} $source->{abs_path}\@$source->{new_snap} "; | |
730 | } else { | |
731 | $cmd .= "$source->{abs_path}\@$source->{new_snap} "; | |
732 | } | |
733 | ||
734 | if ($limit){ | |
735 | my $bwl = $limit*1024; | |
736 | $cmd .= "| cstream -t $bwl"; | |
737 | } | |
738 | $cmd .= "| "; | |
739 | $cmd .= "ssh root\@$dest->{ip} " if $dest->{ip}; | |
740 | $cmd .= "zfs recv $dest->{pool}"; | |
741 | $cmd .= "$dest->{path}" if $dest->{path}; | |
742 | ||
743 | my @dir = split(/\//,$source->{path}); | |
744 | $cmd .= "\/$dir[@dir-1]\@$source->{new_snap}"; | |
745 | ||
746 | eval { | |
747 | run_cmd($cmd) | |
748 | }; | |
749 | ||
750 | if (my $erro = $@) { | |
751 | snapshot_destroy($source, undef, $method, $source->{new_snap}); | |
752 | die $erro; | |
753 | }; | |
754 | ||
755 | if ($source->{vmid}) { | |
756 | if ($method eq "ssh") { | |
757 | send_config($source, $dest,'ssh'); | |
758 | } | |
759 | } | |
760 | } | |
761 | ||
762 | ||
763 | sub send_config{ | |
764 | my ($source, $dest, $method) = @_; | |
765 | ||
766 | if ($method eq 'ssh'){ | |
767 | if ($dest->{ip} && $source->{ip}) { | |
768 | run_cmd("ssh root\@$dest->{ip} mkdir $VMCONFIG -p"); | |
769 | run_cmd("scp root\@$source->{ip}:$QEMU_CONF$source->{vmid}.conf root\@$dest->{ip}:$VMCONFIG$source->{vmid}.conf.$source->{new_snap}"); | |
770 | } elsif ($dest->{ip}) { | |
771 | run_cmd("ssh root\@$dest->{ip} mkdir $VMCONFIG -p"); | |
772 | run_cmd("scp $QEMU_CONF$source->{vmid}.conf root\@$dest->{ip}:$VMCONFIG$source->{vmid}.conf.$source->{new_snap}"); | |
773 | } elsif ($source->{ip}) { | |
774 | run_cmd("mkdir $VMCONFIG -p"); | |
775 | run_cmd("scp root\@$source->{ip}:$QEMU_CONF$source->{vmid}.conf $VMCONFIG$source->{vmid}.conf.$source->{new_snap}"); | |
776 | } | |
777 | ||
778 | if ($source->{destroy}){ | |
779 | if($dest->{ip}){ | |
780 | run_cmd("ssh root\@$dest->{ip} rm -f $VMCONFIG$source->{vmid}.conf.$source->{old_snap}"); | |
781 | } else { | |
782 | run_cmd("rm -f $VMCONFIG$source->{vmid}.conf.$source->{old_snap}"); | |
783 | } | |
784 | } | |
785 | } | |
786 | } | |
787 | ||
788 | sub get_date { | |
789 | my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time); | |
790 | my $datestamp = sprintf ( "%04d-%02d-%02d_%02d:%02d:%02d",$year+1900,$mon+1,$mday,$hour,$min,$sec); | |
791 | ||
792 | return $datestamp; | |
793 | } | |
794 | ||
795 | sub status { | |
796 | my $cfg = read_from_config("$CONFIG_PATH$CONFIG"); | |
797 | ||
798 | my $status_list = sprintf("%-25s%-15s%-10s\n","SOURCE","NAME","STATUS"); | |
799 | ||
ec89fa6d WL |
800 | foreach my $source (sort keys%{$cfg}){ |
801 | foreach my $sync_name (sort keys%{$cfg->{$source}}){ | |
0bc3e510 WL |
802 | my $status; |
803 | ||
804 | my $source_name = $source; | |
805 | ||
806 | $source_name = $cfg->{$source}->{$sync_name}->{source_ip}.":".$source if $cfg->{$source}->{$sync_name}->{source_ip}; | |
807 | ||
808 | if ($cfg->{$source}->{$sync_name}->{locked} eq 'no'){ | |
809 | $status = sprintf("%-10s","OK"); | |
810 | } elsif ($cfg->{$source}->{$sync_name}->{locked} eq 'yes' && | |
811 | $cfg->{$source}->{$sync_name}->{failure}) { | |
812 | $status = sprintf("%-10s","sync error"); | |
813 | } else { | |
814 | $status = sprintf("%-10s","syncing"); | |
815 | } | |
816 | ||
817 | $status_list .= sprintf("%-25s%-15s", cut_to_width($source_name,25), cut_to_width($sync_name,15)); | |
818 | $status_list .= "$status\n"; | |
819 | } | |
820 | } | |
821 | ||
822 | return $status_list; | |
823 | } | |
824 | ||
825 | ||
826 | my $command = $ARGV[0]; | |
827 | ||
828 | my $commands = {'destroy' => 1, | |
829 | 'create' => 1, | |
830 | 'sync' => 1, | |
831 | 'list' => 1, | |
832 | 'status' => 1, | |
833 | 'help' => 1}; | |
834 | ||
835 | if (!$command || !$commands->{$command}) { | |
836 | usage(); | |
837 | die "\n"; | |
838 | } | |
839 | ||
840 | my $dest = ''; | |
841 | my $source = ''; | |
842 | my $verbose = ''; | |
843 | my $interval = ''; | |
844 | my $limit = ''; | |
845 | my $maxsnap = ''; | |
846 | my $name = ''; | |
847 | my $skip = ''; | |
848 | ||
849 | my $help_sync = "zfs-zsync sync -dest <string> -source <string> [OPTIONS]\n | |
850 | \twill sync one time\n | |
851 | \t-dest\tstring\n | |
852 | \t\tthe destination target is like [IP:]<Pool>[/Path]\n | |
853 | \t-limit\tinteger\n | |
854 | \t\tmax sync speed in kBytes/s, default unlimited\n | |
855 | \t-maxsnap\tinteger\n | |
856 | \t\thow much snapshots will be kept before get erased, default 1/n | |
857 | \t-name\tstring\n | |
858 | \t\tname of the sync job, if not set it is default. | |
859 | \tIt is only necessary if scheduler allready contains this source.\n | |
860 | \t-source\tstring\n | |
861 | \t\tthe source can be an <VMID> or [IP:]<ZFSPool>[/Path]\n"; | |
862 | ||
863 | my $help_create = "zfs-zsync create -dest <string> -source <string> [OPTIONS]/n | |
864 | \tCreate a sync Job\n | |
865 | \t-dest\tstringn\n | |
866 | \t\tthe destination target is like [IP]:<Pool>[/Path]\n | |
867 | \t-interval\tinteger\n | |
868 | \t\tthe interval in min in witch the zfs will sync, | |
869 | \t\tdefault is 15 min\n | |
870 | \t-limit\tinteger\n | |
871 | \t\tmax sync speed, default unlimited\n | |
872 | \t-maxsnap\tstring\n | |
873 | \t\thow much snapshots will be kept before get erased, default 1\n | |
874 | \t-name\tstring\n | |
875 | \t\tname of the sync job, if not set it is default\n | |
876 | \t-skip\tboolean\n | |
877 | \t\tif this flag is set it will skip the first sync\n | |
878 | \t-source\tstring\n | |
879 | \t\tthe source can be an <VMID> or [IP:]<ZFSPool>[/Path]\n"; | |
880 | ||
881 | my $help_destroy = "zfs-zsync destroy -source <string> [OPTIONS]\n | |
882 | \tremove a sync Job from the scheduler\n | |
883 | \t-name\tstring\n | |
884 | \t\tname of the sync job, if not set it is default\n | |
885 | \t-source\tstring\n | |
886 | \t\tthe source can be an <VMID> or [IP:]<ZFSPool>[/Path]\n"; | |
887 | ||
888 | my $help_help = "zfs-zsync help <cmd> [OPTIONS]\n | |
889 | \tGet help about specified command.\n | |
890 | \t<cmd>\tstring\n | |
891 | \t\tCommand name\n | |
892 | \t-verbose\tboolean\n | |
893 | \t\tVerbose output format.\n"; | |
894 | ||
895 | my $help_list = "zfs-zsync list\n | |
896 | \tGet a List of all scheduled Sync Jobs\n"; | |
897 | ||
898 | my $help_status = "zfs-zsync status\n | |
899 | \tGet the status of all scheduled Sync Jobs\n"; | |
900 | ||
901 | sub help{ | |
902 | my ($command) = @_; | |
903 | ||
904 | switch($command){ | |
905 | case 'help' | |
906 | { | |
907 | die "$help_help\n"; | |
908 | } | |
909 | case 'sync' | |
910 | { | |
911 | die "$help_sync\n"; | |
912 | } | |
913 | case 'destroy' | |
914 | { | |
915 | die "$help_destroy\n"; | |
916 | } | |
917 | case 'create' | |
918 | { | |
919 | die "$help_create\n"; | |
920 | } | |
921 | case 'list' | |
922 | { | |
923 | die "$help_list\n"; | |
924 | } | |
925 | case 'status' | |
926 | { | |
927 | die "$help_status\n"; | |
928 | } | |
929 | } | |
930 | ||
931 | } | |
932 | ||
933 | my $err = GetOptions ('dest=s' => \$dest, | |
934 | 'source=s' => \$source, | |
935 | 'verbose' => \$verbose, | |
936 | 'interval=i' => \$interval, | |
937 | 'limit=i' => \$limit, | |
938 | 'maxsnap=i' => \$maxsnap, | |
939 | 'name=s' => \$name, | |
940 | 'skip' => \$skip); | |
941 | ||
942 | if ($err == 0) { | |
943 | die "can't parse options\n"; | |
944 | } | |
945 | ||
946 | my $param; | |
947 | $param->{dest} = $dest; | |
948 | $param->{source} = $source; | |
949 | $param->{verbose} = $verbose; | |
950 | $param->{interval} = $interval; | |
951 | $param->{limit} = $limit; | |
952 | $param->{maxsnap} = $maxsnap; | |
953 | $param->{name} = $name; | |
954 | $param->{skip} = $skip; | |
955 | ||
956 | switch($command){ | |
957 | case "destroy" | |
958 | { | |
959 | die "$help_destroy\n" if !$source; | |
960 | check_target($source); | |
961 | destroy($param); | |
962 | } | |
963 | case "sync" | |
964 | { | |
965 | die "$help_sync\n" if !$source || !$dest; | |
966 | check_target($source); | |
967 | check_target($dest); | |
968 | sync($param); | |
969 | } | |
970 | case "create" | |
971 | { | |
972 | die "$help_create\n" if !$source || !$dest; | |
973 | check_target($source); | |
974 | check_target($dest); | |
975 | init($param); | |
976 | } | |
977 | case "status" | |
978 | { | |
979 | print status(); | |
980 | } | |
981 | case "list" | |
982 | { | |
983 | print list(); | |
984 | } | |
985 | case "help" | |
986 | { | |
987 | my $help_command = $ARGV[1]; | |
988 | if ($help_command && $commands->{$help_command}) { | |
989 | print help($help_command); | |
990 | } | |
991 | if ($verbose){ | |
992 | exec("man $PROGNAME"); | |
993 | } else { | |
994 | usage(1); | |
995 | } | |
996 | } | |
997 | } | |
998 | ||
999 | sub usage{ | |
1000 | my ($help) = @_; | |
1001 | ||
1002 | print("ERROR:\tno command specified\n") if !$help; | |
1003 | print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n"); | |
1004 | print("\tpve-zsync help [<cmd>] [OPTIONS]\n\n"); | |
1005 | print("\tpve-zsync create -dest <string> -source <string> [OPTIONS]\n"); | |
1006 | print("\tpve-zsync destroy -source <string> [OPTIONS]\n"); | |
1007 | print("\tpve-zsync list\n"); | |
1008 | print("\tpve-zsync status\n"); | |
1009 | print("\tpve-zsync sync -dest <string> -source <string> [OPTIONS]\n"); | |
1010 | } | |
1011 | ||
1012 | sub check_target{ | |
1013 | my ($target) = @_; | |
1014 | ||
1015 | chomp($target); | |
1016 | ||
1017 | if($target !~ m/(\d+.\d+.\d+.\d+:)?([\w\-\_\/]+)(\/.+)?/){ | |
1018 | print("ERROR:\t$target is not valid.\n\tUse [IP:]<ZFSPool>[/Path]!\n"); | |
1019 | return 1; | |
1020 | } | |
1021 | return undef; | |
1022 | } | |
1023 | ||
1024 | __END__ | |
1025 | ||
1026 | =head1 NAME | |
1027 | ||
1028 | pve-zsync - PVE ZFS Replication Manager | |
1029 | ||
1030 | =head1 SYNOPSIS | |
1031 | ||
1032 | zfs-zsync <COMMAND> [ARGS] [OPTIONS] | |
1033 | ||
1034 | zfs-zsync help <cmd> [OPTIONS] | |
1035 | ||
1036 | Get help about specified command. | |
1037 | ||
1038 | <cmd> string | |
1039 | ||
1040 | Command name | |
1041 | ||
1042 | -verbose boolean | |
1043 | ||
1044 | Verbose output format. | |
1045 | ||
1046 | zfs-zsync create -dest <string> -source <string> [OPTIONS] | |
1047 | ||
1048 | Create a sync Job | |
1049 | ||
1050 | -dest string | |
1051 | ||
1052 | the destination target is like [IP]:<Pool>[/Path] | |
1053 | ||
1054 | -interval integer | |
1055 | ||
1056 | the interval in min in witch the zfs will sync, default is 15 min | |
1057 | ||
1058 | -limit integer | |
1059 | ||
1060 | max sync speed, default unlimited | |
1061 | ||
1062 | -maxsnap string | |
1063 | ||
1064 | how much snapshots will be kept before get erased, default 1 | |
1065 | ||
1066 | -name string | |
1067 | ||
1068 | name of the sync job, if not set it is default | |
1069 | ||
1070 | -skip boolean | |
1071 | ||
1072 | if this flag is set it will skip the first sync | |
1073 | ||
1074 | -source string | |
1075 | ||
1076 | the source can be an <VMID> or [IP:]<ZFSPool>[/Path] | |
1077 | ||
1078 | zfs-zsync destroy -source <string> [OPTIONS] | |
1079 | ||
1080 | remove a sync Job from the scheduler | |
1081 | ||
1082 | -name string | |
1083 | ||
1084 | name of the sync job, if not set it is default | |
1085 | ||
1086 | -source string | |
1087 | ||
1088 | the source can be an <VMID> or [IP:]<ZFSPool>[/Path] | |
1089 | ||
1090 | zfs-zsync list | |
1091 | ||
1092 | Get a List of all scheduled Sync Jobs | |
1093 | ||
1094 | zfs-zsync status | |
1095 | ||
1096 | Get the status of all scheduled Sync Jobs | |
1097 | ||
1098 | zfs-zsync sync -dest <string> -source <string> [OPTIONS] | |
1099 | ||
1100 | will sync one time | |
1101 | ||
1102 | -dest string | |
1103 | ||
1104 | the destination target is like [IP:]<Pool>[/Path] | |
1105 | ||
1106 | -limit integer | |
1107 | ||
1108 | max sync speed in kBytes/s, default unlimited | |
1109 | ||
1110 | -maxsnap integer | |
1111 | ||
1112 | how much snapshots will be kept before get erased, default 1 | |
1113 | ||
1114 | -name string | |
1115 | ||
1116 | name of the sync job, if not set it is default. | |
1117 | It is only necessary if scheduler allready contains this source. | |
1118 | ||
1119 | -source string | |
1120 | ||
1121 | the source can be an <VMID> or [IP:]<ZFSPool>[/Path] | |
1122 | ||
1123 | =head1 DESCRIPTION | |
1124 | ||
1125 | This Tool helps you to sync your VM or directory which stored on ZFS between 2 servers. | |
1126 | This tool also has the capability to add jobs to cron so the sync will be automatically done. | |
1127 | ||
1128 | =head2 PVE ZFS Storage sync Tool | |
1129 | ||
1130 | This Tool can get remote pool on other PVE or send Pool to others ZFS machines | |
1131 | ||
1132 | =head1 EXAMPLES | |
1133 | ||
1134 | add sync job from local VM to remote ZFS Server | |
1135 | zfs-zsync -source=100 -dest=192.168.1.2:zfspool | |
1136 | ||
1137 | =head1 IMPORTANT FILES | |
1138 | ||
1139 | Where the cron jobs are stored /etc/cron.d/pve-zsync | |
1140 | Where the VM config get copied on the destination machine /var/pve-zsync | |
1141 | Where the config is stored /var/pve-zsync | |
1142 | ||
1143 | Copyright (C) 2007-2015 Proxmox Server Solutions GmbH | |
1144 | ||
1145 | This program is free software: you can redistribute it and/or modify it | |
1146 | under the terms of the GNU Affero General Public License as published | |
1147 | by the Free Software Foundation, either version 3 of the License, or | |
1148 | (at your option) any later version. | |
1149 | ||
1150 | This program is distributed in the hope that it will be useful, but | |
1151 | WITHOUT ANY WARRANTY; without even the implied warranty of | |
1152 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |
1153 | Affero General Public License for more details. | |
1154 | ||
1155 | You should have received a copy of the GNU Affero General Public | |
1156 | License along with this program. If not, see | |
1157 | <http://www.gnu.org/licenses/>. |