| File: | blib/lib/App/FileSync/Admin.pm |
| Coverage: | 86.2% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::FileSync::Admin; | |||||
| 2 | ||||||
| 3 | # Server-side administration: the operations behind filesync-admin. Every | |||||
| 4 | # method works directly on the server root; none of them talks to a client. | |||||
| 5 | ||||||
| 6 | 314 314 314 | 1159889 277 4500 | use App::FileSync::Platform; | |||
| 7 | ||||||
| 8 | 314 314 314 | 519 240 2608 | use strict; | |||
| 9 | 314 314 314 | 470 1845 8004 | use warnings; | |||
| 10 | 314 314 314 | 3618 87769 904 | use autodie qw(:all); | |||
| 11 | ||||||
| 12 | 314 314 314 | 761587 318 4349 | use File::Spec; | |||
| 13 | 314 314 314 | 588 219 8670 | use List::Util qw(any); | |||
| 14 | 314 314 314 | 3665 25494 6271 | use Readonly; | |||
| 15 | 314 314 314 | 3802 413322 1101 | use Sub::Private { mode => 'enforce' }; | |||
| 16 | ||||||
| 17 | 314 314 314 | 35629 249 802 | use parent 'App::FileSync::Base'; | |||
| 18 | ||||||
| 19 | 314 314 314 | 10469 288 8420 | use App::FileSync qw(json_decode validate_relpath validate_repo_name validate_client_id parse_since parse_until); | |||
| 20 | 314 314 314 | 44863 426 8745 | use App::FileSync::Args qw(named positional); | |||
| 21 | 314 314 314 | 53500 399 5136 | use App::FileSync::Config; | |||
| 22 | 314 314 314 | 691 246 14233 | use App::FileSync::Constants qw($DEFAULT_SERVER_ROOT $ROLE_CLIENT @ROLES); | |||
| 23 | 314 314 314 | 635 230 5066 | use App::FileSync::I18N qw(os_error); | |||
| 24 | 314 314 314 | 47640 451 6178 | use App::FileSync::Ignore; | |||
| 25 | 314 314 314 | 694 248 7644 | use App::FileSync::IO qw(ensure_dir list_dir open_file sha256_file); | |||
| 26 | 314 314 314 | 56651 675 5936 | use App::FileSync::Settings; | |||
| 27 | 314 314 314 | 59706 517 5194 | use App::FileSync::Store; | |||
| 28 | 314 314 314 | 716 249 2175 | use App::FileSync::Trash; | |||
| 29 | 314 314 314 | 49485 426 4377 | use App::FileSync::Verify; | |||
| 30 | ||||||
| 31 | 314 314 314 | 644 246 525 | use version; our $VERSION = version->declare('0.001.1'); | |||
| 32 | ||||||
| 33 | # The per-repo metadata directories a new repo starts with. | |||||
| 34 | Readonly my @META_DIRS => qw(clients trash journal locks); | |||||
| 35 | ||||||
| 36 | # Journal files are one per month, named YYYY-MM.jsonl. | |||||
| 37 | Readonly my $JOURNAL_FILE => qr/\.jsonl\z/; | |||||
| 38 | ||||||
| 39 | # A trash date argument. | |||||
| 40 | Readonly my $DATE => qr/\A\d{4}-\d{2}-\d{2}\z/; | |||||
| 41 | ||||||
| 42 | # Arguments most methods take. | |||||
| 43 | my %REPO = (repo => { type => 'string', min => 1 }); | |||||
| 44 | ||||||
| 45 | =encoding utf8 | |||||
| 46 | ||||||
| 47 - 109 | =head1 NAME
App::FileSync::Admin - server-side repo, client and recovery administration
=head1 SYNOPSIS
my $admin = App::FileSync::Admin->new(root => '/srv/filesync');
$admin->repo_create(name => 'docs');
$admin->client_allow(repo => 'docs', client => 'laptop');
my $report = $admin->verify(repo => 'docs', deep => 1);
=head1 DESCRIPTION
The library behind C<filesync-admin>. Methods return hashrefs or arrayrefs
for the command line to print (or encode as JSON) and raise catalogued
errors (see L<App::FileSync::I18N>) on failure.
=head1 METHODS
=head2 new
Purpose: an administrator for one server root.
Args: optional C<root> (default C<FILESYNC_ROOT>, then F</srv/filesync>),
C<logger> (default from L<App::FileSync::Settings>), C<language>.
Returns: the object.
Side Effects: builds a logger from the settings when none is given.
Usage:
my $admin = App::FileSync::Admin->new(root => $root);
=head3 EXAMPLE
my $admin = App::FileSync::Admin->new;
print $admin->root, "\n"; # /srv/filesync unless FILESYNC_ROOT says otherwise
=head3 API SPECIFICATION
=head4 INPUT
{
root => { type => 'string', optional => 1 },
logger => { type => 'object', optional => 1 },
language => { type => 'string', optional => 1 },
}
=head4 OUTPUT
{ type => 'object', isa => 'App::FileSync::Admin' }
=head3 MESSAGES
Key Meaning Resolution
args.invalid an argument is malformed fix the call
=head3 FORMAL SPECIFICATION
root' = first non-empty â¨root, FILESYNC_ROOT, "/srv/filesync"â©
=cut | |||||
| 110 | ||||||
| 111 | sub new { | |||||
| 112 | 133 | 49551840 | my $class = shift; | |||
| 113 | 133 | 1188 | my $args = named(\@_, { | |||
| 114 | root => { type => 'string', optional => 1 }, | |||||
| 115 | logger => { type => 'object', optional => 1 }, | |||||
| 116 | language => { type => 'string', optional => 1 }, | |||||
| 117 | }, 'Admin->new'); | |||||
| 118 | ||||||
| 119 | 133 399 | 505 1267 | my ($root) = grep { defined && length } $args->{root}, $ENV{FILESYNC_ROOT}, $DEFAULT_SERVER_ROOT; | |||
| 120 | ||||||
| 121 | return bless { | |||||
| 122 | root => $root, | |||||
| 123 | language => $args->{language}, | |||||
| 124 | 133 | 1576 | logger => $args->{logger} || App::FileSync::Settings::logger(), | |||
| 125 | }, $class; | |||||
| 126 | } | |||||
| 127 | ||||||
| 128 - 164 | =head2 root
Purpose: the server root this object administers.
Args: none.
Returns: the path.
Side Effects: none.
Usage:
my $root = $admin->root;
=head3 EXAMPLE
print $admin->root, "\n";
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'self', type => 'object' } ]
=head4 OUTPUT
{ type => 'string', min => 1 }
=head3 MESSAGES
None.
=head3 FORMAL SPECIFICATION
root = self.root
=cut | |||||
| 165 | ||||||
| 166 | sub root { | |||||
| 167 | 30 | 41 | my ($self) = @_; | |||
| 168 | ||||||
| 169 | 30 | 200 | return $self->{root}; | |||
| 170 | } | |||||
| 171 | ||||||
| 172 - 237 | =head2 repo_create
Purpose: create a repo: its master tree, metadata directories, policy and an
empty index.
Args: C<name> (a repo id), optional C<trash_days>, C<max_deletes>,
C<max_delete_percent> (non-negative integers) and C<ignore> (patterns; the
default ignore list when absent or empty).
Returns: C<{ name, master, config }> (the master and config paths).
Side Effects: creates F<ROOT/master/NAME> and F<ROOT/.filesync/repos/NAME>.
Usage:
$admin->repo_create(name => 'docs', trash_days => 60);
=head3 EXAMPLE
my $made = $admin->repo_create(name => 'photos', ignore => [ '*.tmp', '/cache/' ]);
print "master at $made->{master}\n";
=head3 API SPECIFICATION
=head4 INPUT
{
name => { type => 'string', min => 1, max => 64 },
trash_days => { type => 'integer', min => 0, optional => 1 },
max_deletes => { type => 'integer', min => 0, optional => 1 },
max_delete_percent => { type => 'integer', min => 0, optional => 1 },
ignore => { type => 'arrayref', optional => 1 },
}
=head4 OUTPUT
{
type => 'hashref',
schema => {
name => { type => 'string' },
master => { type => 'string' },
config => { type => 'string' },
},
}
=head3 MESSAGES
Key Meaning Resolution
admin.bad_repo the name is not a valid id letters, digits, . _ - ; at most 64
admin.repo_exists the repo already exists choose another name
io.mkdir / io.write the files cannot be written check permissions
=head3 FORMAL SPECIFICATION
ÎFS
pre name â Id â§ repo_config_path(root, name) â dom FS
post repo_config_path(root, name) â dom FS' â§ index(name) = â
=head3 PSEUDOCODE
validate the name; refuse if the repo exists
start from the default policy; apply the given limits and ignore list
create the master and metadata directories
save the policy and an empty index
=cut | |||||
| 238 | ||||||
| 239 | sub repo_create { | |||||
| 240 | 77 | 426 | my $self = shift; | |||
| 241 | 77 | 827 | my $args = named(\@_, { | |||
| 242 | name => { type => 'string', optional => 1 }, | |||||
| 243 | trash_days => { type => 'integer', min => 0, optional => 1 }, | |||||
| 244 | max_deletes => { type => 'integer', min => 0, optional => 1 }, | |||||
| 245 | max_delete_percent => { type => 'integer', min => 0, optional => 1 }, | |||||
| 246 | ignore => { type => 'arrayref', optional => 1 }, | |||||
| 247 | }, 'Admin::repo_create'); | |||||
| 248 | ||||||
| 249 | 77 | 373 | my $name = $self->_valid_repo($args->{name}); | |||
| 250 | ||||||
| 251 | 77 | 354 | my $config_path = App::FileSync::Config::repo_config_path($self->{root}, $name); | |||
| 252 | 77 | 759 | $self->_throw('admin.repo_exists', { repo => $name, path => $config_path }) if -e $config_path; | |||
| 253 | ||||||
| 254 | 77 | 155 | my $config = App::FileSync::Config::default_repo_config($name); | |||
| 255 | 77 | 1731 | for my $limit (qw(trash_days max_deletes max_delete_percent)) { | |||
| 256 | 231 | 298 | $config->{$limit} = $args->{$limit} + 0 if defined $args->{$limit}; | |||
| 257 | } | |||||
| 258 | # A copy: the caller's list must not become the stored policy's storage. | |||||
| 259 | 77 0 0 | 158 0 0 | $config->{ignore} = [ @{ $args->{ignore} } ] if $args->{ignore} && @{ $args->{ignore} }; | |||
| 260 | ||||||
| 261 | 77 | 269 | my $master = File::Spec->catdir($self->{root}, 'master', $name); | |||
| 262 | 77 | 139 | my $meta = App::FileSync::Config::repo_meta_dir($self->{root}, $name); | |||
| 263 | 77 308 | 284 1382 | ensure_dir($_) for $master, map { File::Spec->catdir($meta, $_) } @META_DIRS; | |||
| 264 | ||||||
| 265 | 77 | 313 | App::FileSync::Config::save_repo($self->{root}, $name, $config); | |||
| 266 | 77 | 248 | $self->_store($name, $config)->save_index({}); | |||
| 267 | ||||||
| 268 | 77 | 1110 | return { name => $name, master => $master, config => $config_path }; | |||
| 269 | } | |||||
| 270 | ||||||
| 271 - 308 | =head2 repo_list
Purpose: the repos under this root.
Args: none.
Returns: an arrayref of names, sorted.
Side Effects: reads the metadata directory.
Usage:
print "$_\n" for @{ $admin->repo_list };
=head3 EXAMPLE
my $repos = $admin->repo_list;
print @{$repos} ? "@{$repos}\n" : "none\n";
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'self', type => 'object' } ]
=head4 OUTPUT
{ type => 'arrayref' }
=head3 MESSAGES
None; a root with no repos gives an empty list.
=head3 FORMAL SPECIFICATION
repo_list = sort { n | n â children(root/.filesync/repos) â§ isdir(n) }
=cut | |||||
| 309 | ||||||
| 310 | sub repo_list { | |||||
| 311 | 1 | 1 | my ($self) = @_; | |||
| 312 | ||||||
| 313 | 1 | 9 | my $root = File::Spec->catdir($self->{root}, '.filesync', 'repos'); | |||
| 314 | 1 1 1 | 1 11 3 | return [ grep { -d File::Spec->catdir($root, $_) } @{ list_dir($root) || [] } ]; | |||
| 315 | } | |||||
| 316 | ||||||
| 317 - 356 | =head2 repo_show
Purpose: a repo's policy and where it lives.
Args: C<$name>.
Returns: C<{ config, master, meta, clients }>; C<clients> is a sorted arrayref.
Side Effects: reads the repo config.
Usage:
my $info = $admin->repo_show('docs');
=head3 EXAMPLE
my $info = $admin->repo_show('docs');
print "trash kept $info->{config}{trash_days} days\n";
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'name', type => 'string', min => 1 } ]
=head4 OUTPUT
{ type => 'hashref' }
=head3 MESSAGES
Key Meaning Resolution
admin.bad_repo the name is not an id check the spelling
config.no_repo the repo does not exist filesync-admin repo list
=head3 FORMAL SPECIFICATION
repo_show(n) = â¨config ⦠load_repo(n), master ⦠master_dir(n), meta ⦠meta_dir(n), clients ⦠sort dom config.clientsâ©
=cut | |||||
| 357 | ||||||
| 358 | sub repo_show { | |||||
| 359 | 0 | 0 | my ($self, $name) = @_; | |||
| 360 | ||||||
| 361 | 0 | 0 | my $store = $self->_store($name); | |||
| 362 | return { | |||||
| 363 | config => $store->config, | |||||
| 364 | master => $store->master_dir(), | |||||
| 365 | meta => $store->meta_dir(), | |||||
| 366 | 0 0 | 0 0 | clients => [ sort keys %{ $store->config->{clients} } ], | |||
| 367 | }; | |||||
| 368 | } | |||||
| 369 | ||||||
| 370 - 418 | =head2 client_allow
Purpose: let a client (or replica) sync a repo.
Args: C<repo>, C<client> (a client id), optional C<role> (C<client>, the
default, or C<replica>).
Returns: C<{ client, role }>.
Side Effects: rewrites the repo config. Allowing an existing client again
changes its role.
Usage:
$admin->client_allow(repo => 'docs', client => 'backup', role => 'replica');
=head3 EXAMPLE
$admin->client_allow(repo => 'docs', client => 'laptop');
=head3 API SPECIFICATION
=head4 INPUT
{
repo => { type => 'string', min => 1 },
client => { type => 'string', optional => 1 },
role => { type => 'string', optional => 1, memberof => [ 'client', 'replica' ] },
}
=head4 OUTPUT
{ type => 'hashref', schema => { client => { type => 'string' }, role => { type => 'string' } } }
=head3 MESSAGES
Key Meaning Resolution
admin.bad_client the client id is not valid letters, digits, . _ - ; at most 64
admin.bad_role the role is not client/replica choose one of those
admin.bad_repo the repo name is not an id check the spelling
config.no_repo the repo does not exist create it first
=head3 FORMAL SPECIFICATION
ÎRepoConfig
pre client â Id â§ role â {client, replica}
clients' = clients â {client ⦠role}
=cut | |||||
| 419 | ||||||
| 420 | sub client_allow { | |||||
| 421 | 78 | 1934 | my $self = shift; | |||
| 422 | 78 | 555 | my $args = named(\@_, { | |||
| 423 | %REPO, | |||||
| 424 | client => { type => 'string', optional => 1 }, | |||||
| 425 | role => { type => 'string', optional => 1 }, | |||||
| 426 | }, 'Admin::client_allow'); | |||||
| 427 | ||||||
| 428 | 78 | 260 | my $client = $self->_valid_client($args->{client}); | |||
| 429 | 77 | 143 | my $role = $args->{role} || $ROLE_CLIENT; | |||
| 430 | $self->_throw('admin.bad_role', { role => $role, roles => join(', ', @ROLES) }) | |||||
| 431 | 77 86 | 448 413 | unless any { $_ eq $role } @ROLES; | |||
| 432 | ||||||
| 433 | 77 | 448 | my $repo = $self->_valid_repo($args->{repo}); | |||
| 434 | 77 | 169 | my $config = App::FileSync::Config::load_repo($self->{root}, $repo); | |||
| 435 | 76 | 183 | $config->{clients}{$client} = { role => $role }; | |||
| 436 | 76 | 149 | App::FileSync::Config::save_repo($self->{root}, $repo, $config); | |||
| 437 | ||||||
| 438 | 76 | 432 | return { client => $client, role => $role }; | |||
| 439 | } | |||||
| 440 | ||||||
| 441 - 479 | =head2 client_list
Purpose: the clients allowed on a repo.
Args: C<$repo>.
Returns: an arrayref, sorted by id, of C<{ client, role, base }>; C<base> is 1
when the server holds that client's base (it has synced at least once).
Side Effects: reads the repo config.
Usage:
for my $c (@{ $admin->client_list('docs') }) { ... }
=head3 EXAMPLE
printf "%-12s %s\n", $_->{client}, $_->{role} for @{ $admin->client_list('docs') };
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'repo', type => 'string', min => 1 } ]
=head4 OUTPUT
{ type => 'arrayref' }
=head3 MESSAGES
Key Meaning Resolution
config.no_repo the repo does not exist check the name
=head3 FORMAL SPECIFICATION
client_list(r) = sort ⨠(c, role(c), base(c) â dom FS) | c â dom clients(r) â©
=cut | |||||
| 480 | ||||||
| 481 | sub client_list { | |||||
| 482 | 0 | 0 | my ($self, $repo) = @_; | |||
| 483 | ||||||
| 484 | 0 | 0 | my $store = $self->_store($repo); | |||
| 485 | 0 | 0 | my $clients = $store->config->{clients}; | |||
| 486 | ||||||
| 487 | # A hand-edited config may hold an id that is not one; list it without a base. | |||||
| 488 | return [ | |||||
| 489 | map { | |||||
| 490 | 0 | 0 | my $entry = $clients->{$_}; | |||
| 491 | 0 0 | 0 0 | my $base = eval { $store->base_path($_) }; | |||
| 492 | { | |||||
| 493 | client => $_, | |||||
| 494 | 0 | 0 | role => (ref($entry) eq 'HASH' && $entry->{role}) || $ROLE_CLIENT, | |||
| 495 | base => (defined $base && -e $base) ? 1 : 0, | |||||
| 496 | } | |||||
| 497 | 0 0 | 0 0 | } sort keys %{$clients} | |||
| 498 | ]; | |||||
| 499 | } | |||||
| 500 | ||||||
| 501 - 545 | =head2 client_forget
Purpose: drop the server's base for a client, so its next sync starts afresh.
Args: C<repo>, C<client>.
Returns: C<{ removed =E<gt> 0|1, path }>.
Side Effects: removes the client's base file. The client stays allowed.
Usage:
$admin->client_forget(repo => 'docs', client => 'laptop');
=head3 EXAMPLE
my $done = $admin->client_forget(repo => 'docs', client => 'old-laptop');
print $done->{removed} ? "forgot\n" : "nothing to forget\n";
=head3 API SPECIFICATION
=head4 INPUT
{
repo => { type => 'string', min => 1 },
client => { type => 'string', min => 1 },
}
=head4 OUTPUT
{ type => 'hashref', schema => { removed => { type => 'integer' }, path => { type => 'string' } } }
=head3 MESSAGES
Key Meaning Resolution
store.bad_client the client id is not valid check the spelling
io.remove the base cannot be removed check permissions
=head3 FORMAL SPECIFICATION
ÎFS
FS' = {base_path(r, c)} ⩤ FS
removed = 1 â base_path(r, c) â dom FS
=cut | |||||
| 546 | ||||||
| 547 | sub client_forget { | |||||
| 548 | 4 | 28 | my $self = shift; | |||
| 549 | 4 | 21 | my $args = named(\@_, { %REPO, client => { type => 'string', min => 1 } }, 'Admin::client_forget'); | |||
| 550 | ||||||
| 551 | # base_path validates the id, so "../x" cannot reach outside the repo. | |||||
| 552 | 4 | 13 | my $path = $self->_store($args->{repo})->base_path($args->{client}); | |||
| 553 | 3 | 54 | return { removed => 0, path => $path } unless -e $path; | |||
| 554 | ||||||
| 555 | 3 3 3 | 5 7 624 | eval { unlink $path; 1 } or $self->_throw('io.remove', { path => $path, error => os_error($@) }); | |||
| 556 | 3 | 11 | return { removed => 1, path => $path }; | |||
| 557 | } | |||||
| 558 | ||||||
| 559 - 596 | =head2 journal_dir
Purpose: where a repo's journal lives.
Args: C<$repo>.
Returns: F<ROOT/.filesync/repos/REPO/journal>.
Side Effects: reads the repo config.
Usage:
my $dir = $admin->journal_dir('docs');
=head3 EXAMPLE
print $admin->journal_dir('docs'), "\n";
=head3 API SPECIFICATION
=head4 INPUT
[ { name => 'repo', type => 'string', min => 1 } ]
=head4 OUTPUT
{ type => 'string', min => 1 }
=head3 MESSAGES
Key Meaning Resolution
config.no_repo the repo does not exist check the name
=head3 FORMAL SPECIFICATION
journal_dir(r) = meta_dir(r) / "journal"
=cut | |||||
| 597 | ||||||
| 598 | sub journal_dir { | |||||
| 599 | 88 | 130 | my ($self, $repo) = @_; | |||
| 600 | ||||||
| 601 | 88 | 257 | return File::Spec->catdir($self->_store($repo)->meta_dir(), 'journal'); | |||
| 602 | } | |||||
| 603 | ||||||
| 604 - 665 | =head2 journal_read
Purpose: the journal's events, filtered.
Args: C<repo>; optional filters C<client> (id), C<since> and C<until>
(C<YYYY-MM-DD> or epoch, inclusive), C<exits> (arrayref of exit codes; selects
session outcomes), C<warnings> (sessions that kept conflict copies) and
C<limit> (the newest N).
Returns: an arrayref of event hashrefs, oldest first.
Side Effects: reads every monthly journal file. A torn final line from a
killed run is skipped.
Usage:
my $events = $admin->journal_read(repo => 'docs', since => '2026-10-01', limit => 50);
=head3 EXAMPLE
for my $event (@{ $admin->journal_read(repo => 'docs', exits => [ 1, 2 ]) }) {
print "$event->{ts} $event->{client} exit $event->{exit}\n";
}
=head3 API SPECIFICATION
=head4 INPUT
{
repo => { type => 'string', min => 1 },
client => { type => 'string', optional => 1 },
since => { type => 'string', optional => 1 },
until => { type => 'string', optional => 1 },
exits => { type => 'arrayref', optional => 1 },
warnings => { type => 'boolean', optional => 1 },
limit => { type => 'integer', optional => 1 },
}
=head4 OUTPUT
{ type => 'arrayref' }
=head3 MESSAGES
Key Meaning Resolution
when.bad since/until is not a date use YYYY-MM-DD or an epoch
io.read a journal file cannot be read check permissions
=head3 FORMAL SPECIFICATION
let E = ⨠e | f â sort(journal files), line â f, e = json(line) â HASH â©
journal_read = last limit (sort_by ts (filter(E)))
=head3 PSEUDOCODE
read every journal file in name (month) order, skipping torn lines
keep events for the client, inside since..until
keep session outcomes with a wanted exit code, if asked
keep sessions that kept conflict copies, if asked
sort by time; keep the newest limit
=cut | |||||
| 666 | ||||||
| 667 | sub journal_read { | |||||
| 668 | 60 | 14031 | my $self = shift; | |||
| 669 | 60 | 606 | my $args = named(\@_, { | |||
| 670 | %REPO, | |||||
| 671 | client => { type => 'string', optional => 1 }, | |||||
| 672 | since => { type => 'string', optional => 1 }, | |||||
| 673 | until => { type => 'string', optional => 1 }, | |||||
| 674 | exits => { type => 'arrayref', optional => 1 }, | |||||
| 675 | warnings => { type => 'scalar', optional => 1 }, | |||||
| 676 | limit => { type => 'integer', optional => 1 }, | |||||
| 677 | }, 'Admin::journal_read'); | |||||
| 678 | ||||||
| 679 | # list_dir, not glob: glob splits a root containing spaces into words. | |||||
| 680 | 60 | 258 | my $dir = $self->journal_dir($args->{repo}); | |||
| 681 | 60 59 59 60 | 621 652 198 151 | my @files = map { File::Spec->catfile($dir, $_) } grep { $_ =~ $JOURNAL_FILE } @{ list_dir($dir) || [] }; | |||
| 682 | ||||||
| 683 | 60 | 176 | my $keep = $self->_event_filter($args); | |||
| 684 | 59 691 727 58 58 | 83 611 466 60 121 | my @events = sort { ($a->{ts} || 0) <=> ($b->{ts} || 0) } grep { $keep->($_) } map { @{ _read_events($_) } } @files; | |||
| 685 | ||||||
| 686 | 59 | 239 | my $limit = $args->{limit}; | |||
| 687 | 59 | 220 | splice(@events, 0, @events - $limit) if defined $limit && $limit > 0 && @events > $limit; | |||
| 688 | 59 | 493 | return \@events; | |||
| 689 | } | |||||
| 690 | ||||||
| 691 - 736 | =head2 verify
Purpose: rehash the master against its index and report what no longer
agrees.
Args: C<repo>, optional C<deep> (hash every file) and C<sample> (hash N
random files).
Returns: the L<App::FileSync::Verify> report, plus C<repo> and C<master>.
Side Effects: reads the master tree; logs a debug summary.
Usage:
my $report = $admin->verify(repo => 'docs', deep => 1);
=head3 EXAMPLE
my $report = $admin->verify(repo => 'docs', sample => 20);
print $report->{ok} ? "clean\n" : "drift\n";
=head3 API SPECIFICATION
=head4 INPUT
{
repo => { type => 'string', min => 1 },
deep => { type => 'boolean', optional => 1 },
sample => { type => 'integer', min => 0, optional => 1 },
}
=head4 OUTPUT
{ type => 'hashref' }
=head3 MESSAGES
Key Meaning Resolution
config.no_repo the repo does not exist check the name
verify.* see App::FileSync::Verify
=head3 FORMAL SPECIFICATION
verify(r) = Verify(master_dir(r), index(r), ignore(r)) â â¨repo ⦠r, master ⦠master_dir(r)â©
=cut | |||||
| 737 | ||||||
| 738 | sub verify { | |||||
| 739 | 15 | 5141 | my $self = shift; | |||
| 740 | 15 | 141 | my $args = named(\@_, { | |||
| 741 | %REPO, | |||||
| 742 | deep => { type => 'scalar', optional => 1 }, | |||||
| 743 | sample => { type => 'integer', min => 0, optional => 1 }, | |||||
| 744 | }, 'Admin::verify'); | |||||
| 745 | ||||||
| 746 | 15 | 34 | my $repo = $args->{repo}; | |||
| 747 | 15 | 38 | my $store = $self->_store($repo); | |||
| 748 | ||||||
| 749 | my $report = App::FileSync::Verify::verify( | |||||
| 750 | root => $store->master_dir(), | |||||
| 751 | entries => $store->load_index(), | |||||
| 752 | ignore => App::FileSync::Ignore->new(patterns => $store->config->{ignore}), | |||||
| 753 | deep => $args->{deep}, | |||||
| 754 | sample => $args->{sample}, | |||||
| 755 | 15 | 44 | ); | |||
| 756 | 15 15 | 148 38 | @{$report}{qw(repo master)} = ($repo, $store->master_dir()); | |||
| 757 | ||||||
| 758 | $self->{logger}->debug('verify finished', { | |||||
| 759 | repo => $repo, | |||||
| 760 | files => $report->{files}, | |||||
| 761 | 45 45 | 29 118 | (map { $_ => scalar @{ $report->{$_} } } qw(modified missing extra)), | |||
| 762 | 15 | 35 | ok => $report->{ok} ? 1 : 0, | |||
| 763 | }); | |||||
| 764 | ||||||
| 765 | 15 | 556 | return $report; | |||
| 766 | } | |||||
| 767 | ||||||
| 768 - 826 | =head2 reindex
Purpose: rebuild F<master.json> from the tree, hashing every file.
Used after a manual repair (a rollback from a backup, say) so the index
describes the restored master without waiting for the next client session.
The old index is deliberately not reused: a restored file can keep its size
and mtime while its content differs, which is exactly the case reindex exists
for.
Args: C<repo>, optional C<dry_run>.
Returns: C<{ repo, master, index, files, dirs, bytes, skipped, dry_run, written }>.
Side Effects: holds the repo lock (the same one the server takes), so it never
races a session; writes the index and a journal event unless C<dry_run>.
Usage:
my $result = $admin->reindex(repo => 'docs');
=head3 EXAMPLE
my $plan = $admin->reindex(repo => 'docs', dry_run => 1);
print "would index $plan->{files} files\n";
=head3 API SPECIFICATION
=head4 INPUT
{
repo => { type => 'string', min => 1 },
dry_run => { type => 'boolean', optional => 1 },
}
=head4 OUTPUT
{ type => 'hashref' }
=head3 MESSAGES
Key Meaning Resolution
admin.locked a sync session holds the repo lock retry when it has finished
config.no_repo the repo does not exist check the name
=head3 FORMAL SPECIFICATION
ÎFS
pre lock(r) is free
¬dry_run â index'(r) = scan(master_dir(r))
=head3 PSEUDOCODE
open the repo and take its lock
walk the master, hashing everything
unless dry run: save the index and journal the reindex
log and report the counts
=cut | |||||
| 827 | ||||||
| 828 | sub reindex { | |||||
| 829 | 7 | 16 | my $self = shift; | |||
| 830 | 7 | 39 | my $args = named(\@_, { %REPO, dry_run => { type => 'scalar', optional => 1 } }, 'Admin::reindex'); | |||
| 831 | ||||||
| 832 | 7 | 17 | my $repo = $args->{repo}; | |||
| 833 | 7 | 25 | my $store = $self->_store($repo); | |||
| 834 | 7 | 34 | my $dry_run = $args->{dry_run} ? 1 : 0; | |||
| 835 | ||||||
| 836 | # Held until $lock goes out of scope at the end of this method. | |||||
| 837 | 7 | 25 | my $lock = $store->acquire_lock() or $self->_throw('admin.locked', { repo => $repo }); | |||
| 838 | ||||||
| 839 | 6 6 | 8 23 | my ($manifest, $stats, $skipped) = @{ $store->walk_master({}) }{qw(manifest stats skipped)}; | |||
| 840 | 6 18 | 12 26 | my %counts = (map { $_ => $stats->{$_} } qw(files dirs bytes)); | |||
| 841 | ||||||
| 842 | 6 | 14 | unless ($dry_run) { | |||
| 843 | 4 | 11 | $store->save_index($manifest); | |||
| 844 | 4 4 | 20 32 | $store->journal({ action => 'reindex', %counts, skipped => scalar(keys %{$skipped}), result => 'ok' }); | |||
| 845 | } | |||||
| 846 | ||||||
| 847 | $self->{logger}->info('reindexed master', { | |||||
| 848 | 6 6 | 18 31 | repo => $repo, %counts, skipped => scalar(keys %{$skipped}), dry_run => $dry_run, | |||
| 849 | }); | |||||
| 850 | ||||||
| 851 | return { | |||||
| 852 | 6 | 218 | repo => $repo, | |||
| 853 | master => $store->master_dir(), | |||||
| 854 | index => $store->index_path(), | |||||
| 855 | %counts, | |||||
| 856 | skipped => $skipped, | |||||
| 857 | dry_run => $dry_run, | |||||
| 858 | written => $dry_run ? 0 : 1, | |||||
| 859 | }; | |||||
| 860 | } | |||||
| 861 | ||||||
| 862 - 930 | =head2 restore
Purpose: put a path back from the server trash: into the master by default,
or into C<to> as an export.
Args: C<repo>, C<path> (the relative path as synced), optional C<to> (a
directory), C<date> (C<YYYY-MM-DD>; the newest version from that day), C<list>
(only list the versions).
Returns: with C<list>, C<{ repo, path, versions }> (rows from
L<App::FileSync::Trash/version_info>); otherwise
C<{ repo, path, from, to, date, sha256, into_master }>.
Side Effects: copies the version out (the trash entry is kept, so this is
repeatable); when restoring into the master, rebuilds the index so verify and
the next sync agree; journals the restore.
Usage:
$admin->restore(repo => 'docs', path => 'notes/a.txt');
=head3 EXAMPLE
my $done = $admin->restore(repo => 'docs', path => 'a.txt', to => '/tmp/out', date => '2026-10-20');
print "restored to $done->{to}\n";
=head3 API SPECIFICATION
=head4 INPUT
{
repo => { type => 'string', min => 1 },
path => { type => 'string', min => 1 },
to => { type => 'string', optional => 1 },
date => { type => 'string', optional => 1 },
list => { type => 'boolean', optional => 1 },
}
=head4 OUTPUT
{ type => 'hashref' }
=head3 MESSAGES
Key Meaning Resolution
admin.unsafe_path the path fails validate_relpath fix the path
admin.no_versions nothing of that path is in the trash check "restore --list"
admin.bad_date the date is not YYYY-MM-DD fix it
admin.no_version_on no version from that day pick a listed date
admin.not_dir the export target is not a directory choose another
admin.exists the destination exists move it aside first
=head3 FORMAL SPECIFICATION
ÎFS
pre path â SafePath â§ versions(path) â â¨â© â§ dest â dom FS
post tree'(dest) = tree(chosen) â§ (into_master â index' = scan(master))
=head3 PSEUDOCODE
validate the path and list its trashed versions
if listing, return them
choose the newest version, or the newest from the given day
destination: the master, or the export directory (created if needed)
refuse to overwrite; copy the version out
if into the master, rebuild the index
journal, log and report
=cut | |||||
| 931 | ||||||
| 932 | sub restore { | |||||
| 933 | 12 | 5257 | my $self = shift; | |||
| 934 | 12 | 90 | my $args = named(\@_, { | |||
| 935 | %REPO, | |||||
| 936 | path => { type => 'string', optional => 1 }, | |||||
| 937 | to => { type => 'string', optional => 1 }, | |||||
| 938 | date => { type => 'string', optional => 1 }, | |||||
| 939 | list => { type => 'scalar', optional => 1 }, | |||||
| 940 | }, 'Admin::restore'); | |||||
| 941 | ||||||
| 942 | 12 12 | 27 22 | my ($repo, $path) = @{$args}{qw(repo path)}; | |||
| 943 | 12 | 29 | my ($safe, $reason) = validate_relpath($path); | |||
| 944 | 12 | 19 | $self->_throw('admin.unsafe_path', { path => $path // '', reason => $reason }) unless $safe; | |||
| 945 | ||||||
| 946 | 12 | 27 | my $store = $self->_store($repo); | |||
| 947 | 12 | 24 | my $versions = App::FileSync::Trash::versions(root => $store->trash_dir(), rel => $path); | |||
| 948 | ||||||
| 949 | 12 | 20 | if ($args->{list}) { | |||
| 950 | 3 4 3 | 5 221 4 | return { repo => $repo, path => $path, versions => [ map { App::FileSync::Trash::version_info($_) } @{$versions} ] }; | |||
| 951 | } | |||||
| 952 | ||||||
| 953 | 9 | 23 | my $chosen = $self->_choose_version($versions, $args); | |||
| 954 | ||||||
| 955 | 5 | 18 | my $into_master = !(defined $args->{to} && length $args->{to}); | |||
| 956 | 5 | 12 | my $base = $into_master ? $store->master_dir() : $args->{to}; | |||
| 957 | 5 | 12 | unless ($into_master) { | |||
| 958 | 3 | 6 | ensure_dir($base); | |||
| 959 | 3 | 14 | $self->_throw('admin.not_dir', { path => $base }) unless -d $base; | |||
| 960 | } | |||||
| 961 | ||||||
| 962 | 5 | 24 | my $destination = File::Spec->catfile($base, split m{/}, $path); | |||
| 963 | 5 | 54 | $self->_throw('admin.exists', { path => $destination }) if -e $destination || -l $destination; | |||
| 964 | ||||||
| 965 | 4 | 8 | App::FileSync::Trash::restore(src => $chosen, to => $destination); | |||
| 966 | 4 | 12 | my $sha = sha256_file($destination); | |||
| 967 | ||||||
| 968 | # The index is a cache; rebuild it so verify and the next sync agree that | |||||
| 969 | # the restored tree is what the master now holds. | |||||
| 970 | 4 | 9 | $store->save_index($store->walk_master($store->load_index())->{manifest}) if $into_master; | |||
| 971 | ||||||
| 972 | 4 | 19 | my %moved = (path => $path, to => $destination, (defined $sha ? (sha256 => $sha) : ())); | |||
| 973 | 4 | 28 | $store->journal({ action => 'restore', %moved, trash => $chosen, result => 'ok' }); | |||
| 974 | 4 | 30 | $self->{logger}->info('restored path', { repo => $repo, path => $path, from => $chosen, to => $destination }); | |||
| 975 | ||||||
| 976 | return { | |||||
| 977 | 4 | 178 | repo => $repo, | |||
| 978 | %moved, | |||||
| 979 | from => $chosen, | |||||
| 980 | date => App::FileSync::Trash::date_of($chosen), | |||||
| 981 | into_master => $into_master ? 1 : 0, | |||||
| 982 | }; | |||||
| 983 | } | |||||
| 984 | ||||||
| 985 | # _store | |||||
| 986 | # Purpose: open a repo after checking its name. | |||||
| 987 | # Entry: $repo is the caller's name; $config optional (saves a read). | |||||
| 988 | # Exit: returns an App::FileSync::Store. | |||||
| 989 | # Side effects: reads the repo config unless given; raises admin.bad_repo or | |||||
| 990 | # config.no_repo. | |||||
| 991 | sub _store :Private { | |||||
| 992 | my ($self, $repo, $config) = @_; | |||||
| 993 | ||||||
| 994 | return App::FileSync::Store->new( | |||||
| 995 | root => $self->{root}, | |||||
| 996 | repo => $self->_valid_repo($repo), | |||||
| 997 | language => $self->{language}, | |||||
| 998 | ($config ? (config => $config) : ()), | |||||
| 999 | ); | |||||
| 1000 | 314 314 314 | 372511 288 3796 | } | |||
| 1001 | ||||||
| 1002 | # _valid_repo / _valid_client | |||||
| 1003 | # Purpose: check an id with the shared validators (which also cap the | |||||
| 1004 | # length), so admin, server and transport agree on what an id is. | |||||
| 1005 | # Entry: the candidate, possibly undef. | |||||
| 1006 | # Exit: returns the id. | |||||
| 1007 | # Side effects: raises admin.bad_repo / admin.bad_client. | |||||
| 1008 | sub _valid_repo :Private { | |||||
| 1009 | my ($self, $name) = @_; | |||||
| 1010 | ||||||
| 1011 | my ($ok, $reason) = validate_repo_name($name); | |||||
| 1012 | $self->_throw('admin.bad_repo', { reason => $reason }) unless $ok; | |||||
| 1013 | return $name; | |||||
| 1014 | 314 314 314 | 100848 318 2576 | } | |||
| 1015 | ||||||
| 1016 | sub _valid_client :Private { | |||||
| 1017 | my ($self, $id) = @_; | |||||
| 1018 | ||||||
| 1019 | my ($ok, $reason) = validate_client_id($id); | |||||
| 1020 | $self->_throw('admin.bad_client', { reason => $reason }) unless $ok; | |||||
| 1021 | return $id; | |||||
| 1022 | 314 314 314 | 93305 276 2397 | } | |||
| 1023 | ||||||
| 1024 | # _event_filter | |||||
| 1025 | # Purpose: turn journal_read's options into one predicate. | |||||
| 1026 | # Entry: $args validated by journal_read. | |||||
| 1027 | # Exit: returns a coderef taking an event, true to keep it. | |||||
| 1028 | # Side effects: raises when.bad for a malformed since/until (before any | |||||
| 1029 | # file is read, since the bounds are parsed here). | |||||
| 1030 | sub _event_filter :Private { | |||||
| 1031 | my ($self, $args) = @_; | |||||
| 1032 | ||||||
| 1033 | my $client = (defined $args->{client} && length $args->{client}) ? $args->{client} : undef; | |||||
| 1034 | my $since = defined $args->{since} ? parse_since($args->{since}) : undef; | |||||
| 1035 | my $until = defined $args->{until} ? parse_until($args->{until}) : undef; | |||||
| 1036 | ||||||
| 1037 | # Only session outcomes record an exit code, so this selects those. | |||||
| 1038 | my %exits = map { ($_ + 0) => 1 } @{ $args->{exits} || [] }; | |||||
| 1039 | ||||||
| 1040 | # The server journals planned conflicts on the plan event too, but only a | |||||
| 1041 | # session outcome shows a run that kept the conflict copies, which is the | |||||
| 1042 | # client's --warnings case. | |||||
| 1043 | my $warnings = $args->{warnings}; | |||||
| 1044 | ||||||
| 1045 | return sub { | |||||
| 1046 | my ($event) = @_; | |||||
| 1047 | my $ts = $event->{ts} || 0; | |||||
| 1048 | return 0 if defined $client && !(defined $event->{client} && $event->{client} eq $client); | |||||
| 1049 | return 0 if (defined $since && $ts < $since) || (defined $until && $ts > $until); | |||||
| 1050 | return 0 if %exits && !(defined $event->{exit} && $exits{ $event->{exit} + 0 }); | |||||
| 1051 | return 0 if $warnings && !(($event->{action} || '') eq 'session' && ($event->{conflicts} || 0) > 0); | |||||
| 1052 | return 1; | |||||
| 1053 | }; | |||||
| 1054 | 314 314 314 | 119398 272 2491 | } | |||
| 1055 | ||||||
| 1056 | # _read_events | |||||
| 1057 | # Purpose: parse one journal file. | |||||
| 1058 | # Entry: $file exists. | |||||
| 1059 | # Exit: returns an arrayref of event hashrefs. | |||||
| 1060 | # Side effects: reads the file; raises io.read if it cannot be opened. | |||||
| 1061 | # Lines that are not JSON objects (a torn final line from a | |||||
| 1062 | # killed run) are skipped, not fatal. | |||||
| 1063 | sub _read_events :Private { | |||||
| 1064 | my ($file) = @_; | |||||
| 1065 | ||||||
| 1066 | my $fh = open_file($file, '<'); | |||||
| 1067 | my @events = grep { ref($_) eq 'HASH' } map { chomp; length ? eval { json_decode($_) } : () } <$fh>; | |||||
| 1068 | close $fh; | |||||
| 1069 | ||||||
| 1070 | return \@events; | |||||
| 1071 | 314 314 314 | 106059 285 2557 | } | |||
| 1072 | ||||||
| 1073 | # _choose_version | |||||
| 1074 | # Purpose: pick the trashed version restore should use. | |||||
| 1075 | # Entry: $versions newest first; $args has path, repo and maybe date. | |||||
| 1076 | # Exit: returns a version path. | |||||
| 1077 | # Side effects: raises admin.no_versions, admin.bad_date or | |||||
| 1078 | # admin.no_version_on. | |||||
| 1079 | sub _choose_version :Private { | |||||
| 1080 | my ($self, $versions, $args) = @_; | |||||
| 1081 | ||||||
| 1082 | my ($repo, $path, $date) = @{$args}{qw(repo path date)}; | |||||
| 1083 | $self->_throw('admin.no_versions', { path => $path, repo => $repo }) unless @{$versions}; | |||||
| 1084 | return $versions->[0] unless defined $date && length $date; | |||||
| 1085 | ||||||
| 1086 | $self->_throw('admin.bad_date', { date => $date }) unless $date =~ $DATE; | |||||
| 1087 | ||||||
| 1088 | # date_of is undef for a path outside the dated layout; such a version | |||||
| 1089 | # never matches a date. | |||||
| 1090 | my ($match) = grep { (App::FileSync::Trash::date_of($_) // '') eq $date } @{$versions}; | |||||
| 1091 | $self->_throw('admin.no_version_on', { path => $path, date => $date }) unless defined $match; | |||||
| 1092 | return $match; | |||||
| 1093 | 314 314 314 | 109719 301 2607 | } | |||
| 1094 | ||||||
| 1095 | 1; | |||||
| 1096 | ||||||