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