MediaWiki master
SwiftFileBackend.php
Go to the documentation of this file.
1<?php
25namespace Wikimedia\FileBackend;
26
27use Exception;
28use LockManager;
29use MapCacheLRU;
32use Psr\Log\LoggerInterface;
33use Shellbox\Command\BoxedCommand;
34use StatusValue;
35use stdClass;
36use Wikimedia\AtEase\AtEase;
44use Wikimedia\RequestTimeout\TimeoutException;
45
56 private const DEFAULT_HTTP_OPTIONS = [ 'httpVersion' => 'v1.1' ];
57 private const AUTH_FAILURE_ERROR = 'Could not connect due to prior authentication failure';
58
60 protected $http;
62 protected $authTTL;
64 protected $swiftAuthUrl;
68 protected $swiftUser;
70 protected $swiftKey;
78 protected $rgwS3AccessKey;
80 protected $rgwS3SecretKey;
82 protected $readUsers;
84 protected $writeUsers;
89
91 protected $srvCache;
92
95
97 protected $authCreds;
99 protected $authErrorTimestamp = null;
100
102 protected $isRGW = false;
103
147 public function __construct( array $config ) {
148 parent::__construct( $config );
149 // Required settings
150 $this->swiftAuthUrl = $config['swiftAuthUrl'];
151 $this->swiftUser = $config['swiftUser'];
152 $this->swiftKey = $config['swiftKey'];
153 // Optional settings
154 $this->authTTL = $config['swiftAuthTTL'] ?? 15 * 60; // some sensible number
155 $this->swiftTempUrlKey = $config['swiftTempUrlKey'] ?? '';
156 $this->canShellboxGetTempUrl = $config['canShellboxGetTempUrl'] ?? false;
157 $this->shellboxIpRange = $config['shellboxIpRange'] ?? null;
158 $this->swiftStorageUrl = $config['swiftStorageUrl'] ?? null;
159 $this->shardViaHashLevels = $config['shardViaHashLevels'] ?? '';
160 $this->rgwS3AccessKey = $config['rgwS3AccessKey'] ?? '';
161 $this->rgwS3SecretKey = $config['rgwS3SecretKey'] ?? '';
162
163 // HTTP helper client
164 $httpOptions = [];
165 foreach ( [ 'connTimeout', 'reqTimeout' ] as $optionName ) {
166 if ( isset( $config[$optionName] ) ) {
167 $httpOptions[$optionName] = $config[$optionName];
168 }
169 }
170 $this->http = new MultiHttpClient( $httpOptions );
171 $this->http->setLogger( $this->logger );
172
173 // Cache container information to mask latency
174 if ( isset( $config['wanCache'] ) && $config['wanCache'] instanceof WANObjectCache ) {
175 $this->memCache = $config['wanCache'];
176 }
177 // Process cache for container info
178 $this->containerStatCache = new MapCacheLRU( 300 );
179 // Cache auth token information to avoid RTTs
180 if ( !empty( $config['cacheAuthInfo'] ) && isset( $config['srvCache'] ) ) {
181 $this->srvCache = $config['srvCache'];
182 } else {
183 $this->srvCache = new EmptyBagOStuff();
184 }
185 $this->readUsers = $config['readUsers'] ?? [];
186 $this->writeUsers = $config['writeUsers'] ?? [];
187 $this->secureReadUsers = $config['secureReadUsers'] ?? [];
188 $this->secureWriteUsers = $config['secureWriteUsers'] ?? [];
189 // Per https://docs.openstack.org/swift/latest/overview_large_objects.html
190 // we need to split objects if they are larger than 5 GB. Support for
191 // splitting objects has not yet been implemented by this class
192 // so limit max file size to 5GiB.
193 $this->maxFileSize = 5 * 1024 * 1024 * 1024;
194 }
195
196 public function setLogger( LoggerInterface $logger ) {
197 parent::setLogger( $logger );
198 $this->http->setLogger( $logger );
199 }
200
201 public function getFeatures() {
202 return (
203 self::ATTR_UNICODE_PATHS |
204 self::ATTR_HEADERS |
205 self::ATTR_METADATA
206 );
207 }
208
209 protected function resolveContainerPath( $container, $relStoragePath ) {
210 if ( !mb_check_encoding( $relStoragePath, 'UTF-8' ) ) {
211 return null; // not UTF-8, makes it hard to use CF and the swift HTTP API
212 } elseif ( strlen( rawurlencode( $relStoragePath ) ) > 1024 ) {
213 return null; // too long for Swift
214 }
215
216 return $relStoragePath;
217 }
218
219 public function isPathUsableInternal( $storagePath ) {
220 [ $container, $rel ] = $this->resolveStoragePathReal( $storagePath );
221 if ( $rel === null ) {
222 return false; // invalid
223 }
224
225 return is_array( $this->getContainerStat( $container ) );
226 }
227
237 protected function extractMutableContentHeaders( array $headers ) {
238 $contentHeaders = [];
239 // Normalize casing, and strip out illegal headers
240 foreach ( $headers as $name => $value ) {
241 $name = strtolower( $name );
242 if ( $name === 'x-delete-at' && is_numeric( $value ) ) {
243 // Expects a Unix Epoch date
244 $contentHeaders[$name] = $value;
245 } elseif ( $name === 'x-delete-after' && is_numeric( $value ) ) {
246 // Expects number of minutes time to live.
247 $contentHeaders[$name] = $value;
248 } elseif ( preg_match( '/^(x-)?content-(?!length$)/', $name ) ) {
249 // Only allow content-* and x-content-* headers (but not content-length)
250 $contentHeaders[$name] = $value;
251 } elseif ( $name === 'content-type' && strlen( $value ) ) {
252 // This header can be set to a value but not unset
253 $contentHeaders[$name] = $value;
254 }
255 }
256 // By default, Swift has annoyingly low maximum header value limits
257 if ( isset( $contentHeaders['content-disposition'] ) ) {
258 $maxLength = 255;
259 // @note: assume FileBackend::makeContentDisposition() already used
260 $offset = $maxLength - strlen( $contentHeaders['content-disposition'] );
261 if ( $offset < 0 ) {
262 $pos = strrpos( $contentHeaders['content-disposition'], ';', $offset );
263 $contentHeaders['content-disposition'] = $pos === false
264 ? ''
265 : trim( substr( $contentHeaders['content-disposition'], 0, $pos ) );
266 }
267 }
268
269 return $contentHeaders;
270 }
271
277 protected function extractMetadataHeaders( array $headers ) {
278 $metadataHeaders = [];
279 foreach ( $headers as $name => $value ) {
280 $name = strtolower( $name );
281 if ( strpos( $name, 'x-object-meta-' ) === 0 ) {
282 $metadataHeaders[$name] = $value;
283 }
284 }
285
286 return $metadataHeaders;
287 }
288
294 protected function getMetadataFromHeaders( array $headers ) {
295 $prefixLen = strlen( 'x-object-meta-' );
296
297 $metadata = [];
298 foreach ( $this->extractMetadataHeaders( $headers ) as $name => $value ) {
299 $metadata[substr( $name, $prefixLen )] = $value;
300 }
301
302 return $metadata;
303 }
304
305 protected function doCreateInternal( array $params ) {
306 $status = $this->newStatus();
307
308 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
309 if ( $dstRel === null ) {
310 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
311
312 return $status;
313 }
314
315 // Headers that are not strictly a function of the file content
316 $mutableHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
317 // Make sure that the "content-type" header is set to something sensible
318 $mutableHeaders['content-type']
319 ??= $this->getContentType( $params['dst'], $params['content'], null );
320
321 $reqs = [ [
322 'method' => 'PUT',
323 'container' => $dstCont,
324 'relPath' => $dstRel,
325 'headers' => array_merge(
326 $mutableHeaders,
327 [
328 'etag' => md5( $params['content'] ),
329 'content-length' => strlen( $params['content'] ),
330 'x-object-meta-sha1base36' =>
331 \Wikimedia\base_convert( sha1( $params['content'] ), 16, 36, 31 )
332 ]
333 ),
334 'body' => $params['content']
335 ] ];
336
337 $method = __METHOD__;
338 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
339 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
340 if ( $rcode === 201 || $rcode === 202 ) {
341 // good
342 } elseif ( $rcode === 412 ) {
343 $status->fatal( 'backend-fail-contenttype', $params['dst'] );
344 } else {
345 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
346 }
347
348 return SwiftFileOpHandle::CONTINUE_IF_OK;
349 };
350
351 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
352 if ( !empty( $params['async'] ) ) { // deferred
353 $status->value = $opHandle;
354 } else { // actually write the object in Swift
355 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
356 }
357
358 return $status;
359 }
360
361 protected function doStoreInternal( array $params ) {
362 $status = $this->newStatus();
363
364 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
365 if ( $dstRel === null ) {
366 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
367
368 return $status;
369 }
370
371 // Open a handle to the source file so that it can be streamed. The size and hash
372 // will be computed using the handle. In the off chance that the source file changes
373 // during this operation, the PUT will fail due to an ETag mismatch and be aborted.
374 AtEase::suppressWarnings();
375 $srcHandle = fopen( $params['src'], 'rb' );
376 AtEase::restoreWarnings();
377 if ( $srcHandle === false ) { // source doesn't exist?
378 $status->fatal( 'backend-fail-notexists', $params['src'] );
379
380 return $status;
381 }
382
383 // Compute the MD5 and SHA-1 hashes in one pass
384 $srcSize = fstat( $srcHandle )['size'];
385 $md5Context = hash_init( 'md5' );
386 $sha1Context = hash_init( 'sha1' );
387 $hashDigestSize = 0;
388 while ( !feof( $srcHandle ) ) {
389 $buffer = (string)fread( $srcHandle, 131_072 ); // 128 KiB
390 hash_update( $md5Context, $buffer );
391 hash_update( $sha1Context, $buffer );
392 $hashDigestSize += strlen( $buffer );
393 }
394 // Reset the handle back to the beginning so that it can be streamed
395 rewind( $srcHandle );
396
397 if ( $hashDigestSize !== $srcSize ) {
398 $status->fatal( 'backend-fail-hash', $params['src'] );
399
400 return $status;
401 }
402
403 // Headers that are not strictly a function of the file content
404 $mutableHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
405 // Make sure that the "content-type" header is set to something sensible
406 $mutableHeaders['content-type']
407 ??= $this->getContentType( $params['dst'], null, $params['src'] );
408
409 $reqs = [ [
410 'method' => 'PUT',
411 'container' => $dstCont,
412 'relPath' => $dstRel,
413 'headers' => array_merge(
414 $mutableHeaders,
415 [
416 'content-length' => $srcSize,
417 'etag' => hash_final( $md5Context ),
418 'x-object-meta-sha1base36' =>
419 \Wikimedia\base_convert( hash_final( $sha1Context ), 16, 36, 31 )
420 ]
421 ),
422 'body' => $srcHandle // resource
423 ] ];
424
425 $method = __METHOD__;
426 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
427 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
428 if ( $rcode === 201 || $rcode === 202 ) {
429 // good
430 } elseif ( $rcode === 412 ) {
431 $status->fatal( 'backend-fail-contenttype', $params['dst'] );
432 } else {
433 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
434 }
435
436 return SwiftFileOpHandle::CONTINUE_IF_OK;
437 };
438
439 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
440 $opHandle->resourcesToClose[] = $srcHandle;
441
442 if ( !empty( $params['async'] ) ) { // deferred
443 $status->value = $opHandle;
444 } else { // actually write the object in Swift
445 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
446 }
447
448 return $status;
449 }
450
451 protected function doCopyInternal( array $params ) {
452 $status = $this->newStatus();
453
454 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
455 if ( $srcRel === null ) {
456 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
457
458 return $status;
459 }
460
461 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
462 if ( $dstRel === null ) {
463 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
464
465 return $status;
466 }
467
468 $reqs = [ [
469 'method' => 'PUT',
470 'container' => $dstCont,
471 'relPath' => $dstRel,
472 'headers' => array_merge(
473 $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
474 [
475 'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
476 str_replace( "%2F", "/", rawurlencode( $srcRel ) )
477 ]
478 )
479 ] ];
480
481 $method = __METHOD__;
482 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
483 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
484 if ( $rcode === 201 ) {
485 // good
486 } elseif ( $rcode === 404 ) {
487 if ( empty( $params['ignoreMissingSource'] ) ) {
488 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
489 }
490 } else {
491 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
492 }
493
494 return SwiftFileOpHandle::CONTINUE_IF_OK;
495 };
496
497 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
498 if ( !empty( $params['async'] ) ) { // deferred
499 $status->value = $opHandle;
500 } else { // actually write the object in Swift
501 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
502 }
503
504 return $status;
505 }
506
507 protected function doMoveInternal( array $params ) {
508 $status = $this->newStatus();
509
510 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
511 if ( $srcRel === null ) {
512 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
513
514 return $status;
515 }
516
517 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
518 if ( $dstRel === null ) {
519 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
520
521 return $status;
522 }
523
524 $reqs = [ [
525 'method' => 'PUT',
526 'container' => $dstCont,
527 'relPath' => $dstRel,
528 'headers' => array_merge(
529 $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
530 [
531 'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
532 str_replace( "%2F", "/", rawurlencode( $srcRel ) )
533 ]
534 )
535 ] ];
536 if ( "{$srcCont}/{$srcRel}" !== "{$dstCont}/{$dstRel}" ) {
537 $reqs[] = [
538 'method' => 'DELETE',
539 'container' => $srcCont,
540 'relPath' => $srcRel,
541 'headers' => []
542 ];
543 }
544
545 $method = __METHOD__;
546 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
547 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
548 if ( $request['method'] === 'PUT' && $rcode === 201 ) {
549 // good
550 } elseif ( $request['method'] === 'DELETE' && $rcode === 204 ) {
551 // good
552 } elseif ( $rcode === 404 ) {
553 if ( empty( $params['ignoreMissingSource'] ) ) {
554 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
555 } else {
556 // Leave Status as OK but skip the DELETE request
557 return SwiftFileOpHandle::CONTINUE_NO;
558 }
559 } else {
560 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
561 }
562
563 return SwiftFileOpHandle::CONTINUE_IF_OK;
564 };
565
566 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
567 if ( !empty( $params['async'] ) ) { // deferred
568 $status->value = $opHandle;
569 } else { // actually move the object in Swift
570 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
571 }
572
573 return $status;
574 }
575
576 protected function doDeleteInternal( array $params ) {
577 $status = $this->newStatus();
578
579 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
580 if ( $srcRel === null ) {
581 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
582
583 return $status;
584 }
585
586 $reqs = [ [
587 'method' => 'DELETE',
588 'container' => $srcCont,
589 'relPath' => $srcRel,
590 'headers' => []
591 ] ];
592
593 $method = __METHOD__;
594 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
595 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
596 if ( $rcode === 204 ) {
597 // good
598 } elseif ( $rcode === 404 ) {
599 if ( empty( $params['ignoreMissingSource'] ) ) {
600 $status->fatal( 'backend-fail-delete', $params['src'] );
601 }
602 } else {
603 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
604 }
605
606 return SwiftFileOpHandle::CONTINUE_IF_OK;
607 };
608
609 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
610 if ( !empty( $params['async'] ) ) { // deferred
611 $status->value = $opHandle;
612 } else { // actually delete the object in Swift
613 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
614 }
615
616 return $status;
617 }
618
619 protected function doDescribeInternal( array $params ) {
620 $status = $this->newStatus();
621
622 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
623 if ( $srcRel === null ) {
624 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
625
626 return $status;
627 }
628
629 // Fetch the old object headers/metadata...this should be in stat cache by now
630 $stat = $this->getFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
631 if ( $stat && !isset( $stat['xattr'] ) ) { // older cache entry
632 $stat = $this->doGetFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
633 }
634 if ( !$stat ) {
635 $status->fatal( 'backend-fail-describe', $params['src'] );
636
637 return $status;
638 }
639
640 // Swift object POST clears any prior headers, so merge the new and old headers here.
641 // Also, during, POST, libcurl adds "Content-Type: application/x-www-form-urlencoded"
642 // if "Content-Type" is not set, which would clobber the header value for the object.
643 $oldMetadataHeaders = [];
644 foreach ( $stat['xattr']['metadata'] as $name => $value ) {
645 $oldMetadataHeaders["x-object-meta-$name"] = $value;
646 }
647 $newContentHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
648 $oldContentHeaders = $stat['xattr']['headers'];
649
650 $reqs = [ [
651 'method' => 'POST',
652 'container' => $srcCont,
653 'relPath' => $srcRel,
654 'headers' => $oldMetadataHeaders + $newContentHeaders + $oldContentHeaders
655 ] ];
656
657 $method = __METHOD__;
658 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
659 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
660 if ( $rcode === 202 ) {
661 // good
662 } elseif ( $rcode === 404 ) {
663 $status->fatal( 'backend-fail-describe', $params['src'] );
664 } else {
665 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
666 }
667 };
668
669 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
670 if ( !empty( $params['async'] ) ) { // deferred
671 $status->value = $opHandle;
672 } else { // actually change the object in Swift
673 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
674 }
675
676 return $status;
677 }
678
682 protected function doPrepareInternal( $fullCont, $dir, array $params ) {
683 $status = $this->newStatus();
684
685 // (a) Check if container already exists
686 $stat = $this->getContainerStat( $fullCont );
687 if ( is_array( $stat ) ) {
688 return $status; // already there
689 } elseif ( $stat === self::RES_ERROR ) {
690 $status->fatal( 'backend-fail-internal', $this->name );
691 $this->logger->error( __METHOD__ . ': cannot get container stat' );
692 } else {
693 // (b) Create container as needed with proper ACLs
694 $params['op'] = 'prepare';
695 $status->merge( $this->createContainer( $fullCont, $params ) );
696 }
697
698 return $status;
699 }
700
701 protected function doSecureInternal( $fullCont, $dir, array $params ) {
702 $status = $this->newStatus();
703 if ( empty( $params['noAccess'] ) ) {
704 return $status; // nothing to do
705 }
706
707 $stat = $this->getContainerStat( $fullCont );
708 if ( is_array( $stat ) ) {
709 $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
710 $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
711 // Make container private to end-users...
712 $status->merge( $this->setContainerAccess(
713 $fullCont,
716 ) );
717 } elseif ( $stat === self::RES_ABSENT ) {
718 $status->fatal( 'backend-fail-usable', $params['dir'] );
719 } else {
720 $status->fatal( 'backend-fail-internal', $this->name );
721 $this->logger->error( __METHOD__ . ': cannot get container stat' );
722 }
723
724 return $status;
725 }
726
727 protected function doPublishInternal( $fullCont, $dir, array $params ) {
728 $status = $this->newStatus();
729 if ( empty( $params['access'] ) ) {
730 return $status; // nothing to do
731 }
732
733 $stat = $this->getContainerStat( $fullCont );
734 if ( is_array( $stat ) ) {
735 $readUsers = array_merge( $this->readUsers, [ $this->swiftUser, '.r:*' ] );
736 if ( !empty( $params['listing'] ) ) {
737 array_push( $readUsers, '.rlistings' );
738 }
739 $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
740
741 // Make container public to end-users...
742 $status->merge( $this->setContainerAccess(
743 $fullCont,
746 ) );
747 } elseif ( $stat === self::RES_ABSENT ) {
748 $status->fatal( 'backend-fail-usable', $params['dir'] );
749 } else {
750 $status->fatal( 'backend-fail-internal', $this->name );
751 $this->logger->error( __METHOD__ . ': cannot get container stat' );
752 }
753
754 return $status;
755 }
756
757 protected function doCleanInternal( $fullCont, $dir, array $params ) {
758 $status = $this->newStatus();
759
760 // Only containers themselves can be removed, all else is virtual
761 if ( $dir != '' ) {
762 return $status; // nothing to do
763 }
764
765 // (a) Check the container
766 $stat = $this->getContainerStat( $fullCont, true );
767 if ( $stat === self::RES_ABSENT ) {
768 return $status; // ok, nothing to do
769 } elseif ( $stat === self::RES_ERROR ) {
770 $status->fatal( 'backend-fail-internal', $this->name );
771 $this->logger->error( __METHOD__ . ': cannot get container stat' );
772 } elseif ( is_array( $stat ) && $stat['count'] == 0 ) {
773 // (b) Delete the container if empty
774 $params['op'] = 'clean';
775 $status->merge( $this->deleteContainer( $fullCont, $params ) );
776 }
777
778 return $status;
779 }
780
781 protected function doGetFileStat( array $params ) {
782 $params = [ 'srcs' => [ $params['src'] ], 'concurrency' => 1 ] + $params;
783 unset( $params['src'] );
784 $stats = $this->doGetFileStatMulti( $params );
785
786 return reset( $stats );
787 }
788
799 protected function convertSwiftDate( $ts, $format = TS_MW ) {
800 try {
801 $timestamp = new MWTimestamp( $ts );
802
803 return $timestamp->getTimestamp( $format );
804 } catch ( TimeoutException $e ) {
805 throw $e;
806 } catch ( Exception $e ) {
807 throw new FileBackendError( $e->getMessage() );
808 }
809 }
810
818 protected function addMissingHashMetadata( array $objHdrs, $path ) {
819 if ( isset( $objHdrs['x-object-meta-sha1base36'] ) ) {
820 return $objHdrs; // nothing to do
821 }
822
824 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
825 $this->logger->error( __METHOD__ . ": {path} was not stored with SHA-1 metadata.",
826 [ 'path' => $path ] );
827
828 $objHdrs['x-object-meta-sha1base36'] = false;
829
830 // Find prior custom HTTP headers
831 $postHeaders = $this->extractMutableContentHeaders( $objHdrs );
832 // Find prior metadata headers
833 $postHeaders += $this->extractMetadataHeaders( $objHdrs );
834
835 $status = $this->newStatus();
837 $scopeLockS = $this->getScopedFileLocks( [ $path ], LockManager::LOCK_UW, $status );
838 if ( $status->isOK() ) {
839 $tmpFile = $this->getLocalCopy( [ 'src' => $path, 'latest' => 1 ] );
840 if ( $tmpFile ) {
841 $hash = $tmpFile->getSha1Base36();
842 if ( $hash !== false ) {
843 $objHdrs['x-object-meta-sha1base36'] = $hash;
844 // Merge new SHA1 header into the old ones
845 $postHeaders['x-object-meta-sha1base36'] = $hash;
846 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
847 [ $rcode ] = $this->requestWithAuth( [
848 'method' => 'POST',
849 'container' => $srcCont,
850 'relPath' => $srcRel,
851 'headers' => $postHeaders
852 ] );
853 if ( $rcode >= 200 && $rcode <= 299 ) {
854 $this->deleteFileCache( $path );
855
856 return $objHdrs; // success
857 }
858 }
859 }
860 }
861
862 $this->logger->error( __METHOD__ . ': unable to set SHA-1 metadata for {path}',
863 [ 'path' => $path ] );
864
865 return $objHdrs; // failed
866 }
867
868 protected function doGetFileContentsMulti( array $params ) {
869 $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
870 // Blindly create tmp files and stream to them, catching any exception
871 // if the file does not exist. Do not waste time doing file stats here.
872 $reqs = []; // (path => op)
873
874 // Initial dummy values to preserve path order
875 $contents = array_fill_keys( $params['srcs'], self::RES_ERROR );
876 foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
877 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
878 if ( $srcRel === null ) {
879 continue; // invalid storage path
880 }
881 // Create a new temporary memory file...
882 $handle = fopen( 'php://temp', 'wb' );
883 if ( $handle ) {
884 $reqs[$path] = [
885 'method' => 'GET',
886 'container' => $srcCont,
887 'relPath' => $srcRel,
888 'headers' => $this->headersFromParams( $params ),
889 'stream' => $handle,
890 ];
891 }
892 }
893
894 $reqs = $this->requestMultiWithAuth(
895 $reqs,
896 [ 'maxConnsPerHost' => $params['concurrency'] ]
897 );
898 foreach ( $reqs as $path => $op ) {
899 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
900 if ( $rcode >= 200 && $rcode <= 299 ) {
901 rewind( $op['stream'] ); // start from the beginning
902 $content = (string)stream_get_contents( $op['stream'] );
903 $size = strlen( $content );
904 // Make sure that stream finished
905 if ( $size === (int)$rhdrs['content-length'] ) {
906 $contents[$path] = $content;
907 } else {
908 $contents[$path] = self::RES_ERROR;
909 $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
910 $this->onError( null, __METHOD__,
911 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
912 }
913 } elseif ( $rcode === 404 ) {
914 $contents[$path] = self::RES_ABSENT;
915 } else {
916 $contents[$path] = self::RES_ERROR;
917 $this->onError( null, __METHOD__,
918 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
919 }
920 fclose( $op['stream'] ); // close open handle
921 }
922
923 return $contents;
924 }
925
926 protected function doDirectoryExists( $fullCont, $dir, array $params ) {
927 $prefix = ( $dir == '' ) ? null : "{$dir}/";
928 $status = $this->objectListing( $fullCont, 'names', 1, null, $prefix );
929 if ( $status->isOK() ) {
930 return ( count( $status->value ) ) > 0;
931 }
932
933 return self::RES_ERROR;
934 }
935
943 public function getDirectoryListInternal( $fullCont, $dir, array $params ) {
944 return new SwiftFileBackendDirList( $this, $fullCont, $dir, $params );
945 }
946
954 public function getFileListInternal( $fullCont, $dir, array $params ) {
955 return new SwiftFileBackendFileList( $this, $fullCont, $dir, $params );
956 }
957
969 public function getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
970 $dirs = [];
971 if ( $after === INF ) {
972 return $dirs; // nothing more
973 }
974
976 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
977
978 $prefix = ( $dir == '' ) ? null : "{$dir}/";
979 // Non-recursive: only list dirs right under $dir
980 if ( !empty( $params['topOnly'] ) ) {
981 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
982 if ( !$status->isOK() ) {
983 throw new FileBackendError( "Iterator page I/O error." );
984 }
985 $objects = $status->value;
986 // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach
987 foreach ( $objects as $object ) { // files and directories
988 if ( substr( $object, -1 ) === '/' ) {
989 $dirs[] = $object; // directories end in '/'
990 }
991 }
992 } else {
993 // Recursive: list all dirs under $dir and its subdirs
994 $getParentDir = static function ( $path ) {
995 return ( $path !== null && strpos( $path, '/' ) !== false ) ? dirname( $path ) : false;
996 };
997
998 // Get directory from last item of prior page
999 $lastDir = $getParentDir( $after ); // must be first page
1000 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
1001
1002 if ( !$status->isOK() ) {
1003 throw new FileBackendError( "Iterator page I/O error." );
1004 }
1005
1006 $objects = $status->value;
1007
1008 // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach
1009 foreach ( $objects as $object ) { // files
1010 $objectDir = $getParentDir( $object ); // directory of object
1011
1012 if ( $objectDir !== false && $objectDir !== $dir ) {
1013 // Swift stores paths in UTF-8, using binary sorting.
1014 // See function "create_container_table" in common/db.py.
1015 // If a directory is not "greater" than the last one,
1016 // then it was already listed by the calling iterator.
1017 if ( strcmp( $objectDir, $lastDir ) > 0 ) {
1018 $pDir = $objectDir;
1019 do { // add dir and all its parent dirs
1020 $dirs[] = "{$pDir}/";
1021 $pDir = $getParentDir( $pDir );
1022 } while ( $pDir !== false
1023 && strcmp( $pDir, $lastDir ) > 0 // not done already
1024 && strlen( $pDir ) > strlen( $dir ) // within $dir
1025 );
1026 }
1027 $lastDir = $objectDir;
1028 }
1029 }
1030 }
1031 // Page on the unfiltered directory listing (what is returned may be filtered)
1032 if ( count( $objects ) < $limit ) {
1033 $after = INF; // avoid a second RTT
1034 } else {
1035 $after = end( $objects ); // update last item
1036 }
1037
1038 return $dirs;
1039 }
1040
1052 public function getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
1053 $files = []; // list of (path, stat map or null) entries
1054 if ( $after === INF ) {
1055 return $files; // nothing more
1056 }
1057
1059 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
1060
1061 $prefix = ( $dir == '' ) ? null : "{$dir}/";
1062 // $objects will contain a list of unfiltered names or stdClass items
1063 // Non-recursive: only list files right under $dir
1064 if ( !empty( $params['topOnly'] ) ) {
1065 if ( !empty( $params['adviseStat'] ) ) {
1066 $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix, '/' );
1067 } else {
1068 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
1069 }
1070 } else {
1071 // Recursive: list all files under $dir and its subdirs
1072 if ( !empty( $params['adviseStat'] ) ) {
1073 $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix );
1074 } else {
1075 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
1076 }
1077 }
1078
1079 // Reformat this list into a list of (name, stat map or null) entries
1080 if ( !$status->isOK() ) {
1081 throw new FileBackendError( "Iterator page I/O error." );
1082 }
1083
1084 $objects = $status->value;
1085 $files = $this->buildFileObjectListing( $objects );
1086
1087 // Page on the unfiltered object listing (what is returned may be filtered)
1088 if ( count( $objects ) < $limit ) {
1089 $after = INF; // avoid a second RTT
1090 } else {
1091 $after = end( $objects ); // update last item
1092 $after = is_object( $after ) ? $after->name : $after;
1093 }
1094
1095 return $files;
1096 }
1097
1105 private function buildFileObjectListing( array $objects ) {
1106 $names = [];
1107 foreach ( $objects as $object ) {
1108 if ( is_object( $object ) ) {
1109 if ( isset( $object->subdir ) || !isset( $object->name ) ) {
1110 continue; // virtual directory entry; ignore
1111 }
1112 $stat = [
1113 // Convert various random Swift dates to TS_MW
1114 'mtime' => $this->convertSwiftDate( $object->last_modified, TS_MW ),
1115 'size' => (int)$object->bytes,
1116 'sha1' => null,
1117 // Note: manifest ETags are not an MD5 of the file
1118 'md5' => ctype_xdigit( $object->hash ) ? $object->hash : null,
1119 'latest' => false // eventually consistent
1120 ];
1121 $names[] = [ $object->name, $stat ];
1122 } elseif ( substr( $object, -1 ) !== '/' ) {
1123 // Omit directories, which end in '/' in listings
1124 $names[] = [ $object, null ];
1125 }
1126 }
1127
1128 return $names;
1129 }
1130
1137 public function loadListingStatInternal( $path, array $val ) {
1138 $this->cheapCache->setField( $path, 'stat', $val );
1139 }
1140
1141 protected function doGetFileXAttributes( array $params ) {
1142 $stat = $this->getFileStat( $params );
1143 // Stat entries filled by file listings don't include metadata/headers
1144 if ( is_array( $stat ) && !isset( $stat['xattr'] ) ) {
1145 $this->clearCache( [ $params['src'] ] );
1146 $stat = $this->getFileStat( $params );
1147 }
1148
1149 if ( is_array( $stat ) ) {
1150 return $stat['xattr'];
1151 }
1152
1153 return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1154 }
1155
1156 protected function doGetFileSha1base36( array $params ) {
1157 // Avoid using stat entries from file listings, which never include the SHA-1 hash.
1158 // Also, recompute the hash if it's not part of the metadata headers for some reason.
1159 $params['requireSHA1'] = true;
1160
1161 $stat = $this->getFileStat( $params );
1162 if ( is_array( $stat ) ) {
1163 return $stat['sha1'];
1164 }
1165
1166 return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1167 }
1168
1169 protected function doStreamFile( array $params ) {
1170 $status = $this->newStatus();
1171
1172 $flags = !empty( $params['headless'] ) ? HTTPFileStreamer::STREAM_HEADLESS : 0;
1173
1174 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1175 if ( $srcRel === null ) {
1176 HTTPFileStreamer::send404Message( $params['src'], $flags );
1177 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
1178
1179 return $status;
1180 }
1181
1182 if ( !is_array( $this->getContainerStat( $srcCont ) ) ) {
1183 HTTPFileStreamer::send404Message( $params['src'], $flags );
1184 $status->fatal( 'backend-fail-stream', $params['src'] );
1185
1186 return $status;
1187 }
1188
1189 // If "headers" is set, we only want to send them if the file is there.
1190 // Do not bother checking if the file exists if headers are not set though.
1191 if ( $params['headers'] && !$this->fileExists( $params ) ) {
1192 HTTPFileStreamer::send404Message( $params['src'], $flags );
1193 $status->fatal( 'backend-fail-stream', $params['src'] );
1194
1195 return $status;
1196 }
1197
1198 // Send the requested additional headers
1199 if ( empty( $params['headless'] ) ) {
1200 foreach ( $params['headers'] as $header ) {
1201 $this->header( $header );
1202 }
1203 }
1204
1205 if ( empty( $params['allowOB'] ) ) {
1206 // Cancel output buffering and gzipping if set
1207 $this->resetOutputBuffer();
1208 }
1209
1210 $handle = fopen( 'php://output', 'wb' );
1211 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1212 'method' => 'GET',
1213 'container' => $srcCont,
1214 'relPath' => $srcRel,
1215 'headers' => $this->headersFromParams( $params ) + $params['options'],
1216 'stream' => $handle,
1217 'flags' => [ 'relayResponseHeaders' => empty( $params['headless'] ) ]
1218 ] );
1219
1220 if ( $rcode >= 200 && $rcode <= 299 ) {
1221 // good
1222 } elseif ( $rcode === 404 ) {
1223 $status->fatal( 'backend-fail-stream', $params['src'] );
1224 // Per T43113, nasty things can happen if bad cache entries get
1225 // stuck in cache. It's also possible that this error can come up
1226 // with simple race conditions. Clear out the stat cache to be safe.
1227 $this->clearCache( [ $params['src'] ] );
1228 $this->deleteFileCache( $params['src'] );
1229 } else {
1230 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1231 }
1232
1233 return $status;
1234 }
1235
1236 protected function doGetLocalCopyMulti( array $params ) {
1237 $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
1238 // Blindly create tmp files and stream to them, catching any exception
1239 // if the file does not exist. Do not waste time doing file stats here.
1240 $reqs = []; // (path => op)
1241
1242 // Initial dummy values to preserve path order
1243 $tmpFiles = array_fill_keys( $params['srcs'], self::RES_ERROR );
1244 foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
1245 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1246 if ( $srcRel === null ) {
1247 continue; // invalid storage path
1248 }
1249 // Get source file extension
1251 // Create a new temporary file...
1252 $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext );
1253 $handle = $tmpFile ? fopen( $tmpFile->getPath(), 'wb' ) : false;
1254 if ( $handle ) {
1255 $reqs[$path] = [
1256 'method' => 'GET',
1257 'container' => $srcCont,
1258 'relPath' => $srcRel,
1259 'headers' => $this->headersFromParams( $params ),
1260 'stream' => $handle,
1261 ];
1262 $tmpFiles[$path] = $tmpFile;
1263 }
1264 }
1265
1266 // Ceph RADOS Gateway is in use (strong consistency) or X-Newest will be used
1267 $latest = ( $this->isRGW || !empty( $params['latest'] ) );
1268
1269 $reqs = $this->requestMultiWithAuth(
1270 $reqs,
1271 [ 'maxConnsPerHost' => $params['concurrency'] ]
1272 );
1273 foreach ( $reqs as $path => $op ) {
1274 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1275 fclose( $op['stream'] ); // close open handle
1276 if ( $rcode >= 200 && $rcode <= 299 ) {
1278 $tmpFile = $tmpFiles[$path];
1279 // Make sure that the stream finished and fully wrote to disk
1280 $size = $tmpFile->getSize();
1281 if ( $size !== (int)$rhdrs['content-length'] ) {
1282 $tmpFiles[$path] = self::RES_ERROR;
1283 $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
1284 $this->onError( null, __METHOD__,
1285 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
1286 }
1287 // Set the file stat process cache in passing
1288 $stat = $this->getStatFromHeaders( $rhdrs );
1289 $stat['latest'] = $latest;
1290 $this->cheapCache->setField( $path, 'stat', $stat );
1291 } elseif ( $rcode === 404 ) {
1292 $tmpFiles[$path] = self::RES_ABSENT;
1293 $this->cheapCache->setField(
1294 $path,
1295 'stat',
1296 $latest ? self::ABSENT_LATEST : self::ABSENT_NORMAL
1297 );
1298 } else {
1299 $tmpFiles[$path] = self::RES_ERROR;
1300 $this->onError( null, __METHOD__,
1301 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
1302 }
1303 }
1304
1305 return $tmpFiles;
1306 }
1307
1308 public function addShellboxInputFile( BoxedCommand $command, string $boxedName,
1309 array $params
1310 ) {
1311 if ( $this->canShellboxGetTempUrl ) {
1312 $urlParams = [ 'src' => $params['src'] ];
1313 if ( $this->shellboxIpRange !== null ) {
1314 $urlParams['ipRange'] = $this->shellboxIpRange;
1315 }
1316 $url = $this->getFileHttpUrl( $urlParams );
1317 if ( $url ) {
1318 $command->inputFileFromUrl( $boxedName, $url );
1319 return $this->newStatus();
1320 }
1321 }
1322 return parent::addShellboxInputFile( $command, $boxedName, $params );
1323 }
1324
1325 public function getFileHttpUrl( array $params ) {
1326 if ( $this->swiftTempUrlKey == '' &&
1327 ( $this->rgwS3AccessKey == '' || $this->rgwS3SecretKey != '' )
1328 ) {
1329 $this->logger->debug( "Can't get Swift file URL: no key available" );
1330 return self::TEMPURL_ERROR;
1331 }
1332
1333 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1334 if ( $srcRel === null ) {
1335 $this->logger->debug( "Can't get Swift file URL: can't resolve path" );
1336 return self::TEMPURL_ERROR; // invalid path
1337 }
1338
1339 $auth = $this->getAuthentication();
1340 if ( !$auth ) {
1341 $this->logger->debug( "Can't get Swift file URL: authentication failed" );
1342 return self::TEMPURL_ERROR;
1343 }
1344
1345 $method = $params['method'] ?? 'GET';
1346 $ttl = $params['ttl'] ?? 86400;
1347 $expires = time() + $ttl;
1348
1349 if ( $this->swiftTempUrlKey != '' ) {
1350 $url = $this->storageUrl( $auth, $srcCont, $srcRel );
1351 // Swift wants the signature based on the unencoded object name
1352 $contPath = parse_url( $this->storageUrl( $auth, $srcCont ), PHP_URL_PATH );
1353 $messageParts = [
1354 $method,
1355 $expires,
1356 "{$contPath}/{$srcRel}"
1357 ];
1358 $query = [
1359 'temp_url_expires' => $expires,
1360 ];
1361 if ( isset( $params['ipRange'] ) ) {
1362 array_unshift( $messageParts, "ip={$params['ipRange']}" );
1363 $query['temp_url_ip_range'] = $params['ipRange'];
1364 }
1365
1366 $signature = hash_hmac( 'sha1',
1367 implode( "\n", $messageParts ),
1368 $this->swiftTempUrlKey
1369 );
1370 $query = [ 'temp_url_sig' => $signature ] + $query;
1371
1372 return $url . '?' . http_build_query( $query );
1373 } else { // give S3 API URL for rgw
1374 // Path for signature starts with the bucket
1375 $spath = '/' . rawurlencode( $srcCont ) . '/' .
1376 str_replace( '%2F', '/', rawurlencode( $srcRel ) );
1377 // Calculate the hash
1378 $signature = base64_encode( hash_hmac(
1379 'sha1',
1380 "{$method}\n\n\n{$expires}\n{$spath}",
1381 $this->rgwS3SecretKey,
1382 true // raw
1383 ) );
1384 // See https://s3.amazonaws.com/doc/s3-developer-guide/RESTAuthentication.html.
1385 // Note: adding a newline for empty CanonicalizedAmzHeaders does not work.
1386 // Note: S3 API is the rgw default; remove the /swift/ URL bit.
1387 return str_replace( '/swift/v1', '', $this->storageUrl( $auth ) . $spath ) .
1388 '?' .
1389 http_build_query( [
1390 'Signature' => $signature,
1391 'Expires' => $expires,
1392 'AWSAccessKeyId' => $this->rgwS3AccessKey
1393 ] );
1394 }
1395 }
1396
1397 protected function directoriesAreVirtual() {
1398 return true;
1399 }
1400
1409 protected function headersFromParams( array $params ) {
1410 $hdrs = [];
1411 if ( !empty( $params['latest'] ) ) {
1412 $hdrs['x-newest'] = 'true';
1413 }
1414
1415 return $hdrs;
1416 }
1417
1418 protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
1420 '@phan-var SwiftFileOpHandle[] $fileOpHandles';
1421
1423 $statuses = [];
1424
1425 // Split the HTTP requests into stages that can be done concurrently
1426 $httpReqsByStage = []; // map of (stage => index => HTTP request)
1427 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
1428 $reqs = $fileOpHandle->httpOp;
1429 foreach ( $reqs as $stage => $req ) {
1430 $httpReqsByStage[$stage][$index] = $req;
1431 }
1432 $statuses[$index] = $this->newStatus();
1433 }
1434
1435 // Run all requests for the first stage, then the next, and so on
1436 $reqCount = count( $httpReqsByStage );
1437 for ( $stage = 0; $stage < $reqCount; ++$stage ) {
1438 $httpReqs = $this->requestMultiWithAuth( $httpReqsByStage[$stage] );
1439 foreach ( $httpReqs as $index => $httpReq ) {
1441 $fileOpHandle = $fileOpHandles[$index];
1442 // Run the callback for each request of this operation
1443 $status = $statuses[$index];
1444 ( $fileOpHandle->callback )( $httpReq, $status );
1445 // On failure, abort all remaining requests for this operation. This is used
1446 // in "move" operations to abort the DELETE request if the PUT request fails.
1447 if (
1448 !$status->isOK() ||
1449 $fileOpHandle->state === $fileOpHandle::CONTINUE_NO
1450 ) {
1451 $stages = count( $fileOpHandle->httpOp );
1452 for ( $s = ( $stage + 1 ); $s < $stages; ++$s ) {
1453 unset( $httpReqsByStage[$s][$index] );
1454 }
1455 }
1456 }
1457 }
1458
1459 return $statuses;
1460 }
1461
1484 protected function setContainerAccess( $container, array $readUsers, array $writeUsers ) {
1485 $status = $this->newStatus();
1486
1487 [ $rcode, , , , ] = $this->requestWithAuth( [
1488 'method' => 'POST',
1489 'container' => $container,
1490 'headers' => [
1491 'x-container-read' => implode( ',', $readUsers ),
1492 'x-container-write' => implode( ',', $writeUsers )
1493 ]
1494 ] );
1495
1496 if ( $rcode != 204 && $rcode !== 202 ) {
1497 $status->fatal( 'backend-fail-internal', $this->name );
1498 $this->logger->error( __METHOD__ . ': unexpected rcode value ({rcode})',
1499 [ 'rcode' => $rcode ] );
1500 }
1501
1502 return $status;
1503 }
1504
1513 protected function getContainerStat( $container, $bypassCache = false ) {
1515 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
1516
1517 if ( $bypassCache ) { // purge cache
1518 $this->containerStatCache->clear( $container );
1519 } elseif ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1520 $this->primeContainerCache( [ $container ] ); // check persistent cache
1521 }
1522 if ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1523 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $this->requestWithAuth( [
1524 'method' => 'HEAD',
1525 'container' => $container
1526 ] );
1527
1528 if ( $rcode === 204 ) {
1529 $stat = [
1530 'count' => $rhdrs['x-container-object-count'],
1531 'bytes' => $rhdrs['x-container-bytes-used']
1532 ];
1533 if ( $bypassCache ) {
1534 return $stat;
1535 } else {
1536 $this->containerStatCache->setField( $container, 'stat', $stat ); // cache it
1537 $this->setContainerCache( $container, $stat ); // update persistent cache
1538 }
1539 } elseif ( $rcode === 404 ) {
1540 return self::RES_ABSENT;
1541 } else {
1542 $this->onError( null, __METHOD__,
1543 [ 'cont' => $container ], $rerr, $rcode, $rdesc, $rbody );
1544
1545 return self::RES_ERROR;
1546 }
1547 }
1548
1549 return $this->containerStatCache->getField( $container, 'stat' );
1550 }
1551
1559 protected function createContainer( $container, array $params ) {
1560 $status = $this->newStatus();
1561
1562 // @see SwiftFileBackend::setContainerAccess()
1563 if ( empty( $params['noAccess'] ) ) {
1564 // public
1565 $readUsers = array_merge( $this->readUsers, [ '.r:*', $this->swiftUser ] );
1566 if ( empty( $params['noListing'] ) ) {
1567 array_push( $readUsers, '.rlistings' );
1568 }
1569 $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
1570 } else {
1571 // private
1572 $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
1573 $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
1574 }
1575
1576 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1577 'method' => 'PUT',
1578 'container' => $container,
1579 'headers' => [
1580 'x-container-read' => implode( ',', $readUsers ),
1581 'x-container-write' => implode( ',', $writeUsers )
1582 ]
1583 ] );
1584
1585 if ( $rcode === 201 ) { // new
1586 // good
1587 } elseif ( $rcode === 202 ) { // already there
1588 // this shouldn't really happen, but is OK
1589 } else {
1590 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1591 }
1592
1593 return $status;
1594 }
1595
1603 protected function deleteContainer( $container, array $params ) {
1604 $status = $this->newStatus();
1605
1606 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1607 'method' => 'DELETE',
1608 'container' => $container
1609 ] );
1610
1611 if ( $rcode >= 200 && $rcode <= 299 ) { // deleted
1612 $this->containerStatCache->clear( $container ); // purge
1613 } elseif ( $rcode === 404 ) { // not there
1614 // this shouldn't really happen, but is OK
1615 } elseif ( $rcode === 409 ) { // not empty
1616 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); // race?
1617 } else {
1618 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1619 }
1620
1621 return $status;
1622 }
1623
1636 private function objectListing(
1637 $fullCont, $type, $limit, $after = null, $prefix = null, $delim = null
1638 ) {
1639 $status = $this->newStatus();
1640
1641 $query = [ 'limit' => $limit ];
1642 if ( $type === 'info' ) {
1643 $query['format'] = 'json';
1644 }
1645 if ( $after !== null ) {
1646 $query['marker'] = $after;
1647 }
1648 if ( $prefix !== null ) {
1649 $query['prefix'] = $prefix;
1650 }
1651 if ( $delim !== null ) {
1652 $query['delimiter'] = $delim;
1653 }
1654
1655 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1656 'method' => 'GET',
1657 'container' => $fullCont,
1658 'query' => $query,
1659 ] );
1660
1661 $params = [ 'cont' => $fullCont, 'prefix' => $prefix, 'delim' => $delim ];
1662 if ( $rcode === 200 ) { // good
1663 if ( $type === 'info' ) {
1664 $status->value = FormatJson::decode( trim( $rbody ) );
1665 } else {
1666 $status->value = explode( "\n", trim( $rbody ) );
1667 }
1668 } elseif ( $rcode === 204 ) {
1669 $status->value = []; // empty container
1670 } elseif ( $rcode === 404 ) {
1671 $status->value = []; // no container
1672 } else {
1673 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1674 }
1675
1676 return $status;
1677 }
1678
1679 protected function doPrimeContainerCache( array $containerInfo ) {
1680 foreach ( $containerInfo as $container => $info ) {
1681 $this->containerStatCache->setField( $container, 'stat', $info );
1682 }
1683 }
1684
1685 protected function doGetFileStatMulti( array $params ) {
1686 $stats = [];
1687
1688 $reqs = []; // (path => op)
1689 // (a) Check the containers of the paths...
1690 foreach ( $params['srcs'] as $path ) {
1691 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1692 if ( $srcRel === null ) {
1693 // invalid storage path
1694 $stats[$path] = self::RES_ERROR;
1695 continue;
1696 }
1697
1698 $cstat = $this->getContainerStat( $srcCont );
1699 if ( $cstat === self::RES_ABSENT ) {
1700 $stats[$path] = self::RES_ABSENT;
1701 continue; // ok, nothing to do
1702 } elseif ( $cstat === self::RES_ERROR ) {
1703 $stats[$path] = self::RES_ERROR;
1704 continue;
1705 }
1706
1707 $reqs[$path] = [
1708 'method' => 'HEAD',
1709 'container' => $srcCont,
1710 'relPath' => $srcRel,
1711 'headers' => $this->headersFromParams( $params )
1712 ];
1713 }
1714
1715 // (b) Check the files themselves...
1716 $reqs = $this->requestMultiWithAuth(
1717 $reqs,
1718 [ 'maxConnsPerHost' => $params['concurrency'] ]
1719 );
1720 foreach ( $reqs as $path => $op ) {
1721 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1722 if ( $rcode === 200 || $rcode === 204 ) {
1723 // Update the object if it is missing some headers
1724 if ( !empty( $params['requireSHA1'] ) ) {
1725 $rhdrs = $this->addMissingHashMetadata( $rhdrs, $path );
1726 }
1727 // Load the stat map from the headers
1728 $stat = $this->getStatFromHeaders( $rhdrs );
1729 if ( $this->isRGW ) {
1730 $stat['latest'] = true; // strong consistency
1731 }
1732 } elseif ( $rcode === 404 ) {
1733 $stat = self::RES_ABSENT;
1734 } else {
1735 $stat = self::RES_ERROR;
1736 $this->onError( null, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1737 }
1738 $stats[$path] = $stat;
1739 }
1740
1741 return $stats;
1742 }
1743
1748 protected function getStatFromHeaders( array $rhdrs ) {
1749 // Fetch all of the custom metadata headers
1750 $metadata = $this->getMetadataFromHeaders( $rhdrs );
1751 // Fetch all of the custom raw HTTP headers
1752 $headers = $this->extractMutableContentHeaders( $rhdrs );
1753
1754 return [
1755 // Convert various random Swift dates to TS_MW
1756 'mtime' => $this->convertSwiftDate( $rhdrs['last-modified'], TS_MW ),
1757 // Empty objects actually return no content-length header in Ceph
1758 'size' => isset( $rhdrs['content-length'] ) ? (int)$rhdrs['content-length'] : 0,
1759 'sha1' => $metadata['sha1base36'] ?? null,
1760 // Note: manifest ETags are not an MD5 of the file
1761 'md5' => ctype_xdigit( $rhdrs['etag'] ) ? $rhdrs['etag'] : null,
1762 'xattr' => [ 'metadata' => $metadata, 'headers' => $headers ]
1763 ];
1764 }
1765
1771 protected function getAuthentication() {
1772 if ( $this->authErrorTimestamp !== null ) {
1773 $interval = time() - $this->authErrorTimestamp;
1774 if ( $interval < 60 ) {
1775 $this->logger->debug(
1776 'rejecting request since auth failure occurred {interval} seconds ago',
1777 [ 'interval' => $interval ]
1778 );
1779 return null;
1780 } else { // actually retry this time
1781 $this->authErrorTimestamp = null;
1782 }
1783 }
1784 // Authenticate with proxy and get a session key...
1785 if ( !$this->authCreds ) {
1786 $cacheKey = $this->getCredsCacheKey( $this->swiftUser );
1787 $creds = $this->srvCache->get( $cacheKey ); // credentials
1788 // Try to use the credential cache
1789 if ( isset( $creds['auth_token'] )
1790 && isset( $creds['storage_url'] )
1791 && isset( $creds['expiry_time'] )
1792 && $creds['expiry_time'] > time()
1793 ) {
1794 $this->setAuthCreds( $creds );
1795 } else { // cache miss
1796 $this->refreshAuthentication();
1797 }
1798 }
1799
1800 return $this->authCreds;
1801 }
1802
1808 private function setAuthCreds( ?array $creds ) {
1809 $this->logger->debug( 'Using auth token with expiry_time={expiry_time}',
1810 [
1811 'expiry_time' => isset( $creds['expiry_time'] )
1812 ? gmdate( 'c', $creds['expiry_time'] ) : 'null'
1813 ]
1814 );
1815 $this->authCreds = $creds;
1816 // Ceph RGW does not use <account> in URLs (OpenStack Swift uses "/v1/<account>")
1817 if ( $creds && str_ends_with( $creds['storage_url'], '/v1' ) ) {
1818 $this->isRGW = true; // take advantage of strong consistency in Ceph
1819 }
1820 }
1821
1827 private function refreshAuthentication() {
1828 [ $rcode, , $rhdrs, $rbody, ] = $this->http->run( [
1829 'method' => 'GET',
1830 'url' => "{$this->swiftAuthUrl}/v1.0",
1831 'headers' => [
1832 'x-auth-user' => $this->swiftUser,
1833 'x-auth-key' => $this->swiftKey
1834 ]
1835 ], self::DEFAULT_HTTP_OPTIONS );
1836
1837 if ( $rcode >= 200 && $rcode <= 299 ) { // OK
1838 if ( isset( $rhdrs['x-auth-token-expires'] ) ) {
1839 $ttl = intval( $rhdrs['x-auth-token-expires'] );
1840 } else {
1841 $ttl = $this->authTTL;
1842 }
1843 $expiryTime = time() + $ttl;
1844 $creds = [
1845 'auth_token' => $rhdrs['x-auth-token'],
1846 'storage_url' => $this->swiftStorageUrl ?? $rhdrs['x-storage-url'],
1847 'expiry_time' => $expiryTime,
1848 ];
1849 $this->srvCache->set( $this->getCredsCacheKey( $this->swiftUser ), $creds, $expiryTime );
1850 } elseif ( $rcode === 401 ) {
1851 $this->onError( null, __METHOD__, [], "Authentication failed.", $rcode );
1852 $this->authErrorTimestamp = time();
1853 $creds = null;
1854 } else {
1855 $this->onError( null, __METHOD__, [], "HTTP return code: $rcode", $rcode, $rbody );
1856 $this->authErrorTimestamp = time();
1857 $creds = null;
1858 }
1859 $this->setAuthCreds( $creds );
1860 return $creds;
1861 }
1862
1869 protected function storageUrl( array $creds, $container = null, $object = null ) {
1870 $parts = [ $creds['storage_url'] ];
1871 if ( strlen( $container ?? '' ) ) {
1872 $parts[] = rawurlencode( $container );
1873 }
1874 if ( strlen( $object ?? '' ) ) {
1875 $parts[] = str_replace( "%2F", "/", rawurlencode( $object ) );
1876 }
1877
1878 return implode( '/', $parts );
1879 }
1880
1885 protected function authTokenHeaders( array $creds ) {
1886 return [ 'x-auth-token' => $creds['auth_token'] ];
1887 }
1888
1895 private function getCredsCacheKey( $username ) {
1896 return 'swiftcredentials:' . md5( $username . ':' . $this->swiftAuthUrl );
1897 }
1898
1913 private function requestWithAuth( array $req, array $options = [] ) {
1914 return $this->requestMultiWithAuth( [ $req ], $options )[0]['response'];
1915 }
1916
1926 private function requestMultiWithAuth( array $reqs, $options = [] ) {
1927 $remainingTries = 2;
1928 $auth = $this->getAuthentication();
1929 while ( true ) {
1930 if ( !$auth ) {
1931 foreach ( $reqs as &$req ) {
1932 if ( !isset( $req['response'] ) ) {
1933 $req['response'] = $this->getAuthFailureResponse();
1934 }
1935 }
1936 break;
1937 }
1938 foreach ( $reqs as &$req ) {
1939 '@phan-var array $req'; // Not array[]
1940 if ( isset( $req['response'] ) ) {
1941 // Request was attempted before
1942 // Retry only if it gave a 401 response code
1943 if ( $req['response']['code'] !== 401 ) {
1944 continue;
1945 }
1946 }
1947 $req['headers'] = $this->authTokenHeaders( $auth ) + ( $req['headers'] ?? [] );
1948 $req['url'] = $this->storageUrl( $auth, $req['container'], $req['relPath'] ?? null );
1949 }
1950 unset( $req );
1951 $reqs = $this->http->runMulti( $reqs, $options + self::DEFAULT_HTTP_OPTIONS );
1952 if ( --$remainingTries > 0 ) {
1953 // Retry if any request failed with 401 "not authorized"
1954 foreach ( $reqs as $req ) {
1955 if ( $req['response']['code'] === 401 ) {
1956 $auth = $this->refreshAuthentication();
1957 continue 2;
1958 }
1959 }
1960 }
1961 break;
1962 }
1963 return $reqs;
1964 }
1965
1974 private function getAuthFailureResponse() {
1975 return [
1976 'code' => 0,
1977 0 => 0,
1978 'reason' => '',
1979 1 => '',
1980 'headers' => [],
1981 2 => [],
1982 'body' => '',
1983 3 => '',
1984 'error' => self::AUTH_FAILURE_ERROR,
1985 4 => self::AUTH_FAILURE_ERROR
1986 ];
1987 }
1988
1996 private function isAuthFailureResponse( $code, $error ) {
1997 return $code === 0 && $error === self::AUTH_FAILURE_ERROR;
1998 }
1999
2012 public function onError( $status, $func, array $params, $err = '', $code = 0, $desc = '', $body = '' ) {
2013 if ( $this->isAuthFailureResponse( $code, $err ) ) {
2014 if ( $status instanceof StatusValue ) {
2015 $status->fatal( 'backend-fail-connect', $this->name );
2016 }
2017 // Already logged
2018 return;
2019 }
2020 if ( $status instanceof StatusValue ) {
2021 $status->fatal( 'backend-fail-internal', $this->name );
2022 }
2023 $msg = "HTTP {code} ({desc}) in '{func}' (given '{req_params}')";
2024 $msgParams = [
2025 'code' => $code,
2026 'desc' => $desc,
2027 'func' => $func,
2028 'req_params' => FormatJson::encode( $params ),
2029 ];
2030 if ( $err ) {
2031 $msg .= ': {err}';
2032 $msgParams['err'] = $err;
2033 }
2034 if ( $code == 502 ) {
2035 $msg .= ' ({truncatedBody})';
2036 $msgParams['truncatedBody'] = substr( strip_tags( $body ), 0, 100 );
2037 }
2038 $this->logger->error( $msg, $msgParams );
2039 }
2040}
2041
2043class_alias( SwiftFileBackend::class, 'SwiftFileBackend' );
array $params
The job parameters.
Resource locking handling.
Store key-value entries in a size-limited in-memory LRU cache.
JSON formatter wrapper class.
Library for creating and parsing MW-style timestamps.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
File backend exception for checked exceptions (e.g.
Base class for all backends using particular storage medium.
getContentType( $storagePath, $content, $fsPath)
Get the content type to use in HEAD/GET requests for a file.
resolveStoragePathReal( $storagePath)
Like resolveStoragePath() except null values are returned if the container is sharded and the shard c...
executeOpHandlesInternal(array $fileOpHandles)
Execute a list of FileBackendStoreOpHandle handles in parallel.
setContainerCache( $container, array $val)
Set the cached info for a container.
getFileStat(array $params)
Get quick information about a file at a storage path in the backend.
fileExists(array $params)
Check if a file exists at a storage path in the backend.
clearCache(?array $paths=null)
Invalidate any in-process file stat and property cache.
deleteFileCache( $path)
Delete the cached stat info for a file path.
primeContainerCache(array $items)
Do a batch lookup from cache for container stats for all containers used in a list of container names...
string $name
Unique backend name.
getLocalCopy(array $params)
Get a local copy on disk of the file at a storage path in the backend.
static extensionFromPath( $path, $case='lowercase')
Get the final extension from a storage or FS path.
newStatus(... $args)
Yields the result of the status wrapper callback on either:
getScopedFileLocks(array $paths, $type, StatusValue $status, $timeout=0)
Lock the files at the given storage paths in the backend.
static send404Message( $fname, $flags=0)
Send out a standard 404 message for a file.
Class for an OpenStack Swift (or Ceph RGW) based file backend.
getAuthentication()
Get the cached auth token.
MapCacheLRU $containerStatCache
Container stat cache.
headersFromParams(array $params)
Get headers to send to Swift when reading a file based on a FileBackend params array,...
getFileListInternal( $fullCont, $dir, array $params)
resolveContainerPath( $container, $relStoragePath)
Resolve a relative storage path, checking if it's allowed by the backend.
doCleanInternal( $fullCont, $dir, array $params)
string $swiftTempUrlKey
Shared secret value for making temp URLs.
addMissingHashMetadata(array $objHdrs, $path)
Fill in any missing object metadata and save it to Swift.
array $writeUsers
Additional users (account:user) with write permissions on public containers.
string $swiftAuthUrl
Authentication base URL (without version)
doDirectoryExists( $fullCont, $dir, array $params)
storageUrl(array $creds, $container=null, $object=null)
onError( $status, $func, array $params, $err='', $code=0, $desc='', $body='')
Log an unexpected exception for this backend.
createContainer( $container, array $params)
Create a Swift container.
array $secureWriteUsers
Additional users (account:user) with write permissions on private containers.
doPrimeContainerCache(array $containerInfo)
Fill the backend-specific process cache given an array of resolved container names and their correspo...
array $readUsers
Additional users (account:user) with read permissions on public containers.
convertSwiftDate( $ts, $format=TS_MW)
Convert dates like "Tue, 03 Jan 2012 22:01:04 GMT"/"2013-05-11T07:37:27.678360Z".
getDirectoryListInternal( $fullCont, $dir, array $params)
getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params)
Do not call this function outside of SwiftFileBackendFileList.
deleteContainer( $container, array $params)
Delete a Swift container.
directoriesAreVirtual()
Is this a key/value store where directories are just virtual? Virtual directories exists in so much a...
string $rgwS3AccessKey
S3 access key (RADOS Gateway)
addShellboxInputFile(BoxedCommand $command, string $boxedName, array $params)
Add a file to a Shellbox command as an input file.
doPrepareInternal( $fullCont, $dir, array $params)
FileBackendStore::doPrepare() to override StatusValue Good status without value for success,...
extractMutableContentHeaders(array $headers)
Filter/normalize a header map to only include mutable "content-"/"x-content-" headers.
string $swiftUser
Swift user (account:user) to authenticate as.
getFeatures()
Get the a bitfield of extra features supported by the backend medium.
string $swiftStorageUrl
Override of storage base URL.
doGetFileStatMulti(array $params)
Get file stat information (concurrently if possible) for several files.
loadListingStatInternal( $path, array $val)
Do not call this function outside of SwiftFileBackendFileList.
isPathUsableInternal( $storagePath)
Check if a file can be created or changed at a given storage path in the backend.
setContainerAccess( $container, array $readUsers, array $writeUsers)
Set read/write permissions for a Swift container.
string $rgwS3SecretKey
S3 authentication key (RADOS Gateway)
getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params)
Do not call this function outside of SwiftFileBackendFileList.
array $secureReadUsers
Additional users (account:user) with read permissions on private containers.
bool $isRGW
Whether the server is an Ceph RGW.
int null $authErrorTimestamp
UNIX timestamp.
doSecureInternal( $fullCont, $dir, array $params)
doPublishInternal( $fullCont, $dir, array $params)
getContainerStat( $container, $bypassCache=false)
Get a Swift container stat map, possibly from process cache.
Class to handle multiple HTTP requests.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:89
No-op implementation that stores nothing.
Multi-datacenter aware caching interface.
This program is free software; you can redistribute it and/or modify it under the terms of the GNU Ge...
$header