Completed
Push — master ( b350e3...8d2c4d )
by Arne
01:49
created

Vault::writeSynchronizationListToTemporaryFile()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 19
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 19
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 8
nc 3
nop 1
1
<?php
2
3
namespace Archivr;
4
5
use Archivr\ConnectionAdapter\ConnectionAdapterInterface;
6
use Archivr\Exception\Exception;
7
use Archivr\IndexMerger\IndexMergerInterface;
8
use Archivr\IndexMerger\StandardIndexMerger;
9
use Archivr\LockAdapter\ConnectionBasedLockAdapter;
10
use Archivr\LockAdapter\LockAdapterInterface;
11
use Archivr\OperationCollectionBuilder\OperationCollectionBuilderInterface;
12
use Archivr\OperationCollectionBuilder\StandardOperationCollectionBuilder;
13
use Archivr\SynchronizationProgressListener\DummySynchronizationProgressListener;
14
use Archivr\SynchronizationProgressListener\SynchronizationProgressListenerInterface;
15
use Ramsey\Uuid\Uuid;
16
use Symfony\Component\Finder\Finder;
17
use Symfony\Component\Finder\SplFileInfo;
18
use Archivr\Operation\OperationInterface;
19
20
class Vault
21
{
22
    use TildeExpansionTrait;
23
24
    const METADATA_DIRECTORY_NAME = '.archivr';
25
    const SYNCHRONIZATION_LIST_FILE_NAME = 'index';
26
    const LOCK_SYNC = 'sync';
27
28
    // Operations modes for building operation collection
29
    const MODE_PREFER_LOCAL = 'local';
30
    const MODE_PREFER_REMOTE = 'remote';
31
32
    /**
33
     * @var string
34
     */
35
    protected $title;
36
37
    /**
38
     * @var ConnectionAdapterInterface
39
     */
40
    protected $vaultConnection;
41
42
    /**
43
     * @var string
44
     */
45
    protected $localPath;
46
47
    /**
48
     * @var LockAdapterInterface
49
     */
50
    protected $lockAdapter;
51
52
    /**
53
     * @var IndexMergerInterface
54
     */
55
    protected $indexMerger;
56
57
    /**
58
     * @var OperationCollectionBuilderInterface
59
     */
60
    protected $operationCollectionBuilder;
61
62
    /**
63
     * @var string[]
64
     */
65
    protected $exclusions = [];
66
67
    /**
68
     * @var string
69
     */
70
    protected $identity;
71
72
73
    public function __construct(string $title, string $localPath, ConnectionAdapterInterface $vaultConnection)
74
    {
75
        $this->title = $title;
76
        $this->vaultConnection = $vaultConnection;
77
        $this->localPath = rtrim($this->expandTildePath($localPath), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
78
    }
79
80
    public function getTitle(): string
81
    {
82
        return $this->title;
83
    }
84
85
    public function getLocalPath(): string
86
    {
87
        return $this->localPath;
88
    }
89
90
    public function setIndexMerger(IndexMergerInterface $indexMerger = null)
91
    {
92
        $this->indexMerger = $indexMerger;
93
94
        return $this;
95
    }
96
97
    public function getIndexMerger(): IndexMergerInterface
98
    {
99
        if ($this->indexMerger === null)
100
        {
101
            $this->indexMerger = new StandardIndexMerger();
102
        }
103
104
        return $this->indexMerger;
105
    }
106
107
    public function getOperationCollectionBuilder(): OperationCollectionBuilderInterface
108
    {
109
        if ($this->operationCollectionBuilder === null)
110
        {
111
            $this->operationCollectionBuilder = new StandardOperationCollectionBuilder($this);
112
        }
113
114
        return $this->operationCollectionBuilder;
115
    }
116
117
    public function setOperationCollectionBuilder(OperationCollectionBuilderInterface $operationCollectionBuilder = null): Vault
118
    {
119
        $this->operationCollectionBuilder = $operationCollectionBuilder;
120
121
        return $this;
122
    }
123
124
    public function setLockAdapter(LockAdapterInterface $lockAdapter = null): Vault
125
    {
126
        $this->lockAdapter = $lockAdapter;
127
128
        return $this;
129
    }
130
131
    public function getLockAdapter(): LockAdapterInterface
132
    {
133
        if ($this->lockAdapter === null)
134
        {
135
            $this->lockAdapter = new ConnectionBasedLockAdapter($this->vaultConnection);
136
        }
137
138
        return $this->lockAdapter;
139
    }
140
141
    public function getVaultConnection(): ConnectionAdapterInterface
142
    {
143
        return $this->vaultConnection;
144
    }
145
146
    public function getExclusions(): array
147
    {
148
        return $this->exclusions;
149
    }
150
151
    public function addExclusion(string $path): Vault
152
    {
153
        $this->exclusions[] = $path;
154
155
        return $this;
156
    }
157
158
    public function setExclusions(array $paths): Vault
159
    {
160
        $this->exclusions = array_values($paths);
0 ignored issues
show
Documentation Bug introduced by
It seems like array_values($paths) of type array<integer,?> is incompatible with the declared type array<integer,string> of property $exclusions.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
161
162
        return $this;
163
    }
164
165
    public function getIdentity(): string
166
    {
167
        return $this->identity;
168
    }
169
170
    public function setIdentity(string $identity = null): Vault
171
    {
172
        $this->identity = $identity;
173
174
        return $this;
175
    }
176
177
    /**
178
     * Builds and returns an index representing the current local state.
179
     *
180
     * @return Index
181
     */
182
    public function buildLocalIndex(): Index
183
    {
184
        $finder = new Finder();
185
        $finder->in($this->localPath);
186
        $finder->ignoreDotFiles(false);
187
        $finder->ignoreVCS(true);
188
        $finder->exclude(static::METADATA_DIRECTORY_NAME);
189
        $finder->notPath('archivr.json');
190
191
        foreach ($this->exclusions as $path)
192
        {
193
            $finder->notPath($path);
194
        }
195
196
        $index = new Index();
197
198
        foreach ($finder->directories() as $fileInfo)
199
        {
200
            /** @var SplFileInfo $fileInfo */
201
202
            $index->addObject(IndexObject::fromPath($this->localPath, $fileInfo->getRelativePathname()));
203
        }
204
205
        foreach ($finder->files() as $fileInfo)
206
        {
207
            /** @var SplFileInfo $fileInfo */
208
209
            $index->addObject(IndexObject::fromPath($this->localPath, $fileInfo->getRelativePathname()));
210
        }
211
212
        return $index;
213
    }
214
215
    /**
216
     * Reads and returns the index representing the local state on the last synchronization.
217
     *
218
     * @return Index
219
     * @throws Exception
220
     */
221
    public function loadLastLocalIndex()
222
    {
223
        $index = null;
224
        $path = $this->getLastLocalIndexFilePath();
225
226
        clearstatcache(null, $path);
227
228
        if (is_file($path))
229
        {
230
            $stream = fopen($path, 'rb');
231
            $indexModificationDate = \DateTime::createFromFormat('U', filemtime($path));
232
233
            if (!($indexModificationDate instanceof \DateTime))
234
            {
235
                throw new Exception();
236
            }
237
238
            $index = $this->readIndexFromStream($stream, $indexModificationDate);
239
240
            fclose($stream);
241
        }
242
243
        return $index;
244
    }
245
246
    /**
247
     * Reads and returns the current remote index.
248
     *
249
     * @param int $revision Revision to load. Defaults to the last revision.
250
     *
251
     * @return Index
252
     */
253
    public function loadRemoteIndex(int $revision = null)
254
    {
255
        $list = null;
256
257 View Code Duplication
        if ($revision === null)
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
258
        {
259
            $list = $this->loadSynchronizationList();
260
261
            if (!$list->getLastSynchronization())
262
            {
263
                return null;
264
            }
265
266
            $revision = $list->getLastSynchronization()->getRevision();
267
        }
268
269
        return $this->doLoadRemoteIndex($revision, $list);
270
    }
271
272
    /**
273
     * Computes and returns the index representing the vault state after the local index has been merged with the remote index.
274
     *
275
     * @return Index
276
     */
277
    public function buildMergedIndex(): Index
278
    {
279
        return $this->doBuildMergedIndex();
280
    }
281
282
    /**
283
     * Returns ordered collection of operations required to synchronize the vault with the local path.
284
     * In addition to the object specific operations contained in the returned OperationCollection additional operations
285
     * might be necessary like index updates that do not belong to specific index objects.
286
     *
287
     * @return OperationCollection
288
     */
289
    public function getOperationCollection(): OperationCollection
290
    {
291
        $localIndex = $this->buildLocalIndex();
292
        $lastLocalIndex = $this->loadLastLocalIndex();
293
        $remoteIndex = $this->loadRemoteIndex();
294
295
        $mergedIndex = $this->doBuildMergedIndex($localIndex, $lastLocalIndex, $remoteIndex);
296
297
        return $this->getOperationCollectionBuilder()->buildOperationCollection($mergedIndex, $localIndex, $remoteIndex);
298
    }
299
300
    /**
301
     * Synchronizes the local with the remote state by executing all operations returned by getOperationCollection() (broadly speaking).
302
     *
303
     * @param int $newRevision
304
     * @param bool $preferLocal
305
     * @param SynchronizationProgressListenerInterface $progressionListener
306
     *
307
     * @return OperationResultCollection
308
     * @throws Exception
309
     */
310
    public function synchronize(int $newRevision = null, bool $preferLocal = false, SynchronizationProgressListenerInterface $progressionListener = null): OperationResultCollection
311
    {
312
        if ($progressionListener === null)
313
        {
314
            $progressionListener = new DummySynchronizationProgressListener();
315
        }
316
317
        $localIndex = $this->buildLocalIndex();
318
        $lastLocalIndex = $this->loadLastLocalIndex();
319
320
321
        if (!$this->getLockAdapter()->acquireLock(static::LOCK_SYNC))
322
        {
323
            throw new Exception('Failed to acquire lock.');
324
        }
325
326
327
        $synchronizationList = $this->loadSynchronizationList();
328
        $lastSynchronization = $synchronizationList->getLastSynchronization();
329
330
        if ($lastSynchronization)
331
        {
332
            $newRevision = $newRevision ?: ($lastSynchronization->getRevision() + 1);
333
            $remoteIndex = $this->doLoadRemoteIndex($lastSynchronization->getRevision(), $synchronizationList);
334
        }
335
        else
336
        {
337
            $newRevision = $newRevision ?: 1;
338
            $remoteIndex = null;
339
        }
340
341
        $synchronization = new Synchronization($newRevision, $this->generateNewBlobId(), new \DateTime(), $this->identity);
342
        $synchronizationList->addSynchronization($synchronization);
343
344
        // don't merge indices but just use local
345
        if ($preferLocal)
346
        {
347
            $mergedIndex = $localIndex;
348
        }
349
350
        // compute merged index
351
        else
352
        {
353
            $mergedIndex = $this->doBuildMergedIndex($localIndex, $lastLocalIndex, $remoteIndex);
354
        }
355
356
        $operationCollection = $this->getOperationCollectionBuilder()->buildOperationCollection($mergedIndex, $localIndex, $remoteIndex);
357
358
        $operationResultCollection = new OperationResultCollection();
359
360
        // operation count +
361
        // merged index write +
362
        // copy merged index to vault +
363
        // save merged index as last local index +
364
        // upload synchronization list +
365
        // release lock
366
        $progressionListener->start(count($operationCollection) + 5);
367
368 View Code Duplication
        foreach ($operationCollection as $operation)
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
369
        {
370
            /** @var OperationInterface $operation */
371
372
            $success = $operation->execute();
373
374
            $operationResult = new OperationResult($operation, $success);
375
            $operationResultCollection->addOperationResult($operationResult);
376
377
            $progressionListener->advance();
378
        }
379
380
        // dump new index
381
        $mergedIndexFilePath = tempnam(sys_get_temp_dir(), 'index');
382
        $this->writeIndexToFile($mergedIndex, $mergedIndexFilePath);
383
384
        $progressionListener->advance();
385
386
        // upload new index
387
        $readStream = fopen($mergedIndexFilePath, 'rb');
388
        $compressionFilter = stream_filter_append($readStream, 'zlib.deflate');
389
        $this->vaultConnection->writeStream($synchronization->getBlobId(), $readStream);
390
        rewind($readStream);
391
        stream_filter_remove($compressionFilter);
392
393
        $progressionListener->advance();
394
395
        // save new index locally
396
        $writeStream = fopen($this->getLastLocalIndexFilePath(), 'wb');
397
        stream_copy_to_stream($readStream, $writeStream);
398
        fclose($writeStream);
399
        fclose($readStream);
400
401
        $progressionListener->advance();
402
403
        // upload new synchronization list
404
        $synchronizationListFilePath = $this->writeSynchronizationListToTemporaryFile($synchronizationList);
405
        $readStream = fopen($synchronizationListFilePath, 'rb');
406
        stream_filter_append($readStream, 'zlib.deflate');
407
        $this->vaultConnection->writeStream(static::SYNCHRONIZATION_LIST_FILE_NAME, $readStream);
408
        fclose($readStream);
409
410
        // release lock
411
        if (!$this->getLockAdapter()->releaseLock(static::LOCK_SYNC))
412
        {
413
            throw new Exception('Failed to release lock.');
414
        }
415
416
        $progressionListener->advance();
417
        $progressionListener->finish();
418
419
        return $operationResultCollection;
420
    }
421
422
    /**
423
     * Loads and returns the list of synchronizations from the vault.
424
     *
425
     * @return SynchronizationList
426
     */
427
    public function loadSynchronizationList(): SynchronizationList
428
    {
429
        $list = null;
0 ignored issues
show
Unused Code introduced by
$list is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
430
431
        if ($this->vaultConnection->exists(static::SYNCHRONIZATION_LIST_FILE_NAME))
432
        {
433
            $stream = $this->vaultConnection->getReadStream(static::SYNCHRONIZATION_LIST_FILE_NAME);
434
435
            stream_filter_append($stream, 'zlib.inflate');
436
437
            $list = $this->readSynchronizationListFromStream($stream);
438
439
            fclose($stream);
440
441
            return $list;
442
        }
443
444
        return new SynchronizationList();
445
    }
446
447
    /**
448
     * Restores the local state at the given revision from the vault.
449
     *
450
     * @param int $revision
451
     * @param SynchronizationProgressListenerInterface $progressionListener
452
     *
453
     * @return OperationResultCollection
454
     * @throws Exception
455
     */
456
    public function restore(int $revision = null, SynchronizationProgressListenerInterface $progressionListener = null): OperationResultCollection
457
    {
458
        return $this->doRestore($revision, $progressionListener);
459
    }
460
461
    /**
462
     * @param string $targetPath
463
     * @param int $revision
464
     * @param SynchronizationProgressListenerInterface|null $progressListener
465
     *
466
     * @return OperationResultCollection
467
     * @throws \Exception
468
     */
469
    public function dump(string $targetPath, int $revision = null, SynchronizationProgressListenerInterface $progressListener = null): OperationResultCollection
470
    {
471
        $originalLocalPath = $this->localPath;
472
        $this->localPath =  rtrim($this->expandTildePath($targetPath), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
473
474
        try
475
        {
476
            return $this->doRestore($revision, $progressListener, true);
477
        }
478
        catch (\Exception $exception)
479
        {
480
            throw $exception;
481
        }
482
        finally
483
        {
484
            $this->localPath = $originalLocalPath;
485
        }
486
    }
487
488
    protected function doLoadRemoteIndex(int $revision, SynchronizationList $synchronizationList = null)
489
    {
490
        if ($synchronizationList === null)
491
        {
492
            $synchronizationList = $this->loadSynchronizationList();
493
        }
494
495
        $synchronization = $synchronizationList->getSynchronizationByRevision($revision);
496
497
        if (!$synchronization)
498
        {
499
            return null;
500
        }
501
502
        $index = null;
503
504
        if ($this->vaultConnection->exists($synchronization->getBlobId()))
505
        {
506
            $stream = $this->vaultConnection->getReadStream($synchronization->getBlobId());
507
508
            stream_filter_append($stream, 'zlib.inflate');
509
510
            $index = $this->readIndexFromStream($stream);
511
512
            fclose($stream);
513
        }
514
515
        return $index;
516
    }
517
518
    protected function doBuildMergedIndex(Index $localIndex = null, Index $lastLocalIndex = null, Index $remoteIndex = null)
519
    {
520
        $localIndex = $localIndex ?: $this->buildLocalIndex();
521
        $lastLocalIndex = $lastLocalIndex ?: $this->loadLastLocalIndex();
522
        $remoteIndex = $remoteIndex ?: $this->loadRemoteIndex();
523
524
        if ($remoteIndex === null)
525
        {
526
            return $localIndex;
527
        }
528
529
        return $this->getIndexMerger()->merge($remoteIndex, $localIndex, $lastLocalIndex);
530
    }
531
532
    protected function doRestore(int $revision = null, SynchronizationProgressListenerInterface $progressionListener = null, bool $skipLastLocalIndexUpdate = false): OperationResultCollection
533
    {
534
        if ($progressionListener === null)
535
        {
536
            $progressionListener = new DummySynchronizationProgressListener();
537
        }
538
539
        if (!$this->getLockAdapter()->acquireLock(static::LOCK_SYNC))
540
        {
541
            throw new Exception('Failed to acquire lock.');
542
        }
543
544 View Code Duplication
        if ($revision === null)
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
545
        {
546
            $synchronizationList = $this->loadSynchronizationList();
547
548
            if (!$synchronizationList->getLastSynchronization())
549
            {
550
                throw new Exception('No revision to restore from.');
551
            }
552
553
            $revision = $synchronizationList->getLastSynchronization()->getRevision();
554
        }
555
556
        $remoteIndex = $this->loadRemoteIndex($revision);
557
558
        if ($remoteIndex === null)
559
        {
560
            throw new Exception("Unknown revision: {$revision}");
561
        }
562
563
        $localIndex = $this->buildLocalIndex();
564
565
        $operationCollection = $this->getOperationCollectionBuilder()->buildOperationCollection($remoteIndex, $localIndex, $remoteIndex);
566
567
        $operationResultCollection = new OperationResultCollection();
568
569
        // operation count +
570
        // save merged index as last local index +
571
        // release lock
572
        $progressionListener->start(count($operationCollection) + 2);
573
574 View Code Duplication
        foreach ($operationCollection as $operation)
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
575
        {
576
            /** @var OperationInterface $operation */
577
578
            $success = $operation->execute();
579
580
            $operationResult = new OperationResult($operation, $success);
581
            $operationResultCollection->addOperationResult($operationResult);
582
583
            $progressionListener->advance();
584
        }
585
586
        if (!$skipLastLocalIndexUpdate)
587
        {
588
            $this->writeIndexToFile($remoteIndex, $this->getLastLocalIndexFilePath());
589
        }
590
591
        $progressionListener->advance();
592
593
        if (!$this->getLockAdapter()->releaseLock(static::LOCK_SYNC))
594
        {
595
            throw new Exception('Failed to release lock.');
596
        }
597
598
        $progressionListener->advance();
599
        $progressionListener->finish();
600
601
        return $operationResultCollection;
602
    }
603
604
    protected function readIndexFromStream($stream, \DateTime $created = null): Index
605
    {
606
        if (!is_resource($stream))
607
        {
608
            throw new Exception();
609
        }
610
611
        $index = new Index($created);
612
613
        while (($row = fgetcsv($stream)) !== false)
614
        {
615
            $index->addObject(IndexObject::fromIndexRecord($row));
616
        }
617
618
        return $index;
619
    }
620
621
    protected function writeIndexToFile(Index $index, string $path)
622
    {
623
        $stream = fopen($path, 'wb');
624
625
        foreach ($index as $object)
626
        {
627
            /** @var IndexObject $object */
628
629
            if (fputcsv($stream, $object->getIndexRecord()) === false)
630
            {
631
                throw new Exception();
632
            }
633
        }
634
635
        fclose($stream);
636
    }
637
638
    protected function readSynchronizationListFromStream($stream): SynchronizationList
639
    {
640
        if (!is_resource($stream))
641
        {
642
            throw new Exception();
643
        }
644
645
        $list = new SynchronizationList();
646
647
        while (($row = fgetcsv($stream)) !== false)
648
        {
649
            $list->addSynchronization(Synchronization::fromRecord($row));
650
        }
651
652
        return $list;
653
    }
654
655
    protected function writeSynchronizationListToTemporaryFile(SynchronizationList $synchronizationList): string
656
    {
657
        $path = tempnam(sys_get_temp_dir(), 'synchronizationList');
658
        $stream = fopen($path, 'wb');
659
660
        foreach ($synchronizationList as $synchronization)
661
        {
662
            /** @var Synchronization $synchronization */
663
664
            if (fputcsv($stream, $synchronization->getRecord()) === false)
665
            {
666
                throw new Exception();
667
            }
668
        }
669
670
        fclose($stream);
671
672
        return $path;
673
    }
674
675
    protected function generateNewBlobId(): string
676
    {
677
        do
678
        {
679
            $blobId = Uuid::uuid4()->toString();
680
        }
681
        while ($this->vaultConnection->exists($blobId));
682
683
        return $blobId;
684
    }
685
686
    protected function initMetadataDirectory(): string
687
    {
688
        $path = $this->localPath . static::METADATA_DIRECTORY_NAME;
689
690
        if (!is_dir($path))
691
        {
692
            if (!mkdir($path))
693
            {
694
                throw new Exception();
695
            }
696
        }
697
698
        return $path . DIRECTORY_SEPARATOR;
699
    }
700
701
    protected function getLastLocalIndexFilePath(): string
702
    {
703
        return $this->initMetadataDirectory() . sprintf('lastLocalIndex-%s', $this->getTitle());
704
    }
705
}
706