MediaWiki master
SwiftFileBackend.php
Go to the documentation of this file.
1<?php
11namespace Wikimedia\FileBackend;
12
13use Exception;
14use Psr\Log\LoggerInterface;
15use Shellbox\Command\BoxedCommand;
16use StatusValue;
17use stdClass;
27use Wikimedia\RequestTimeout\TimeoutException;
28use Wikimedia\Timestamp\ConvertibleTimestamp;
29use Wikimedia\Timestamp\TimestampFormat as TS;
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 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
363 $srcHandle = @fopen( $params['src'], 'rb' );
364 if ( $srcHandle === false ) { // source doesn't exist?
365 $status->fatal( 'backend-fail-notexists', $params['src'] );
366
367 return $status;
368 }
369
370 // Compute the MD5 and SHA-1 hashes in one pass
371 $srcSize = fstat( $srcHandle )['size'];
372 $md5Context = hash_init( 'md5' );
373 $sha1Context = hash_init( 'sha1' );
374 $hashDigestSize = 0;
375 while ( !feof( $srcHandle ) ) {
376 $buffer = (string)fread( $srcHandle, 131_072 ); // 128 KiB
377 hash_update( $md5Context, $buffer );
378 hash_update( $sha1Context, $buffer );
379 $hashDigestSize += strlen( $buffer );
380 }
381 // Reset the handle back to the beginning so that it can be streamed
382 rewind( $srcHandle );
383
384 if ( $hashDigestSize !== $srcSize ) {
385 $status->fatal( 'backend-fail-hash', $params['src'] );
386
387 return $status;
388 }
389
390 // Headers that are not strictly a function of the file content
391 $mutableHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
392 // Make sure that the "content-type" header is set to something sensible
393 $mutableHeaders['content-type']
394 ??= $this->getContentType( $params['dst'], null, $params['src'] );
395
396 $reqs = [ [
397 'method' => 'PUT',
398 'container' => $dstCont,
399 'relPath' => $dstRel,
400 'headers' => array_merge(
401 $mutableHeaders,
402 [
403 'content-length' => $srcSize,
404 'etag' => hash_final( $md5Context ),
405 'x-object-meta-sha1base36' =>
406 \Wikimedia\base_convert( hash_final( $sha1Context ), 16, 36, 31 )
407 ]
408 ),
409 'body' => $srcHandle // resource
410 ] ];
411
412 $method = __METHOD__;
413 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
414 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
415 if ( $rcode === 201 || $rcode === 202 ) {
416 // good
417 } elseif ( $rcode === 412 ) {
418 $status->fatal( 'backend-fail-contenttype', $params['dst'] );
419 } else {
420 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
421 }
422
423 return SwiftFileOpHandle::CONTINUE_IF_OK;
424 };
425
426 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
427 $opHandle->resourcesToClose[] = $srcHandle;
428
429 if ( !empty( $params['async'] ) ) { // deferred
430 $status->value = $opHandle;
431 } else { // actually write the object in Swift
432 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
433 }
434
435 return $status;
436 }
437
439 protected function doCopyInternal( array $params ) {
440 $status = $this->newStatus();
441
442 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
443 if ( $srcRel === null ) {
444 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
445
446 return $status;
447 }
448
449 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
450 if ( $dstRel === null ) {
451 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
452
453 return $status;
454 }
455
456 $reqs = [ [
457 'method' => 'PUT',
458 'container' => $dstCont,
459 'relPath' => $dstRel,
460 'headers' => array_merge(
461 $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
462 [
463 'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
464 str_replace( "%2F", "/", rawurlencode( $srcRel ) )
465 ]
466 )
467 ] ];
468
469 $method = __METHOD__;
470 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
471 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
472 if ( $rcode === 201 ) {
473 // good
474 } elseif ( $rcode === 404 ) {
475 if ( empty( $params['ignoreMissingSource'] ) ) {
476 $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
477 }
478 } else {
479 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
480 }
481
482 return SwiftFileOpHandle::CONTINUE_IF_OK;
483 };
484
485 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
486 if ( !empty( $params['async'] ) ) { // deferred
487 $status->value = $opHandle;
488 } else { // actually write the object in Swift
489 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
490 }
491
492 return $status;
493 }
494
496 protected function doMoveInternal( array $params ) {
497 $status = $this->newStatus();
498
499 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
500 if ( $srcRel === null ) {
501 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
502
503 return $status;
504 }
505
506 [ $dstCont, $dstRel ] = $this->resolveStoragePathReal( $params['dst'] );
507 if ( $dstRel === null ) {
508 $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
509
510 return $status;
511 }
512
513 $reqs = [ [
514 'method' => 'PUT',
515 'container' => $dstCont,
516 'relPath' => $dstRel,
517 'headers' => array_merge(
518 $this->extractMutableContentHeaders( $params['headers'] ?? [] ),
519 [
520 'x-copy-from' => '/' . rawurlencode( $srcCont ) . '/' .
521 str_replace( "%2F", "/", rawurlencode( $srcRel ) )
522 ]
523 )
524 ] ];
525 if ( "{$srcCont}/{$srcRel}" !== "{$dstCont}/{$dstRel}" ) {
526 $reqs[] = [
527 'method' => 'DELETE',
528 'container' => $srcCont,
529 'relPath' => $srcRel,
530 'headers' => []
531 ];
532 }
533
534 $method = __METHOD__;
535 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
536 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
537 if ( $request['method'] === 'PUT' && $rcode === 201 ) {
538 // good
539 } elseif ( $request['method'] === 'DELETE' && $rcode === 204 ) {
540 // good
541 } elseif ( $rcode === 404 ) {
542 if ( empty( $params['ignoreMissingSource'] ) ) {
543 $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
544 } else {
545 // Leave Status as OK but skip the DELETE request
546 return SwiftFileOpHandle::CONTINUE_NO;
547 }
548 } else {
549 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
550 }
551
552 return SwiftFileOpHandle::CONTINUE_IF_OK;
553 };
554
555 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
556 if ( !empty( $params['async'] ) ) { // deferred
557 $status->value = $opHandle;
558 } else { // actually move the object in Swift
559 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
560 }
561
562 return $status;
563 }
564
566 protected function doDeleteInternal( array $params ) {
567 $status = $this->newStatus();
568
569 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
570 if ( $srcRel === null ) {
571 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
572
573 return $status;
574 }
575
576 $reqs = [ [
577 'method' => 'DELETE',
578 'container' => $srcCont,
579 'relPath' => $srcRel,
580 'headers' => []
581 ] ];
582
583 $method = __METHOD__;
584 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
585 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
586 if ( $rcode === 204 ) {
587 // good
588 } elseif ( $rcode === 404 ) {
589 if ( empty( $params['ignoreMissingSource'] ) ) {
590 $status->fatal( 'backend-fail-delete', $params['src'] );
591 }
592 } else {
593 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
594 }
595
596 return SwiftFileOpHandle::CONTINUE_IF_OK;
597 };
598
599 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
600 if ( !empty( $params['async'] ) ) { // deferred
601 $status->value = $opHandle;
602 } else { // actually delete the object in Swift
603 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
604 }
605
606 return $status;
607 }
608
610 protected function doDescribeInternal( array $params ) {
611 $status = $this->newStatus();
612
613 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
614 if ( $srcRel === null ) {
615 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
616
617 return $status;
618 }
619
620 // Fetch the old object headers/metadata...this should be in stat cache by now
621 $stat = $this->getFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
622 if ( $stat && !isset( $stat['xattr'] ) ) { // older cache entry
623 $stat = $this->doGetFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
624 }
625 if ( !$stat ) {
626 $status->fatal( 'backend-fail-describe', $params['src'] );
627
628 return $status;
629 }
630
631 // Swift object POST clears any prior headers, so merge the new and old headers here.
632 // Also, during, POST, libcurl adds "Content-Type: application/x-www-form-urlencoded"
633 // if "Content-Type" is not set, which would clobber the header value for the object.
634 $oldMetadataHeaders = [];
635 foreach ( $stat['xattr']['metadata'] as $name => $value ) {
636 $oldMetadataHeaders["x-object-meta-$name"] = $value;
637 }
638 $newContentHeaders = $this->extractMutableContentHeaders( $params['headers'] ?? [] );
639 $oldContentHeaders = $stat['xattr']['headers'];
640
641 $reqs = [ [
642 'method' => 'POST',
643 'container' => $srcCont,
644 'relPath' => $srcRel,
645 'headers' => $oldMetadataHeaders + $newContentHeaders + $oldContentHeaders
646 ] ];
647
648 $method = __METHOD__;
649 $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
650 [ $rcode, $rdesc, , $rbody, $rerr ] = $request['response'];
651 if ( $rcode === 202 ) {
652 // good
653 } elseif ( $rcode === 404 ) {
654 $status->fatal( 'backend-fail-describe', $params['src'] );
655 } else {
656 $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc, $rbody );
657 }
658 };
659
660 $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
661 if ( !empty( $params['async'] ) ) { // deferred
662 $status->value = $opHandle;
663 } else { // actually change the object in Swift
664 $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
665 }
666
667 return $status;
668 }
669
673 protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
674 $status = $this->newStatus();
675
676 // (a) Check if container already exists
677 $stat = $this->getContainerStat( $fullCont );
678 if ( is_array( $stat ) ) {
679 return $status; // already there
680 } elseif ( $stat === self::RES_ERROR ) {
681 $status->fatal( 'backend-fail-internal', $this->name );
682 $this->logger->error( __METHOD__ . ': cannot get container stat' );
683 } else {
684 // (b) Create container as needed with proper ACLs
685 $params['op'] = 'prepare';
686 $status->merge( $this->createContainer( $fullCont, $params ) );
687 }
688
689 return $status;
690 }
691
693 protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
694 $status = $this->newStatus();
695 if ( empty( $params['noAccess'] ) ) {
696 return $status; // nothing to do
697 }
698
699 $stat = $this->getContainerStat( $fullCont );
700 if ( is_array( $stat ) ) {
701 $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
702 $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
703 // Make container private to end-users...
704 $status->merge( $this->setContainerAccess(
705 $fullCont,
706 $readUsers,
707 $writeUsers
708 ) );
709 } elseif ( $stat === self::RES_ABSENT ) {
710 $status->fatal( 'backend-fail-usable', $params['dir'] );
711 } else {
712 $status->fatal( 'backend-fail-internal', $this->name );
713 $this->logger->error( __METHOD__ . ': cannot get container stat' );
714 }
715
716 return $status;
717 }
718
720 protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
721 $status = $this->newStatus();
722 if ( empty( $params['access'] ) ) {
723 return $status; // nothing to do
724 }
725
726 $stat = $this->getContainerStat( $fullCont );
727 if ( is_array( $stat ) ) {
728 $readUsers = array_merge( $this->readUsers, [ $this->swiftUser, '.r:*' ] );
729 if ( !empty( $params['listing'] ) ) {
730 $readUsers[] = '.rlistings';
731 }
732 $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
733
734 // Make container public to end-users...
735 $status->merge( $this->setContainerAccess(
736 $fullCont,
737 $readUsers,
738 $writeUsers
739 ) );
740 } elseif ( $stat === self::RES_ABSENT ) {
741 $status->fatal( 'backend-fail-usable', $params['dir'] );
742 } else {
743 $status->fatal( 'backend-fail-internal', $this->name );
744 $this->logger->error( __METHOD__ . ': cannot get container stat' );
745 }
746
747 return $status;
748 }
749
751 protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
752 $status = $this->newStatus();
753
754 // Only containers themselves can be removed, all else is virtual
755 if ( $dirRel != '' ) {
756 return $status; // nothing to do
757 }
758
759 // (a) Check the container
760 $stat = $this->getContainerStat( $fullCont, true );
761 if ( $stat === self::RES_ABSENT ) {
762 return $status; // ok, nothing to do
763 } elseif ( $stat === self::RES_ERROR ) {
764 $status->fatal( 'backend-fail-internal', $this->name );
765 $this->logger->error( __METHOD__ . ': cannot get container stat' );
766 } elseif ( is_array( $stat ) && $stat['count'] == 0 ) {
767 // (b) Delete the container if empty
768 $params['op'] = 'clean';
769 $status->merge( $this->deleteContainer( $fullCont, $params ) );
770 }
771
772 return $status;
773 }
774
776 protected function doGetFileStat( array $params ) {
777 $params = [ 'srcs' => [ $params['src'] ], 'concurrency' => 1 ] + $params;
778 unset( $params['src'] );
779 $stats = $this->doGetFileStatMulti( $params );
780
781 return reset( $stats );
782 }
783
794 protected function convertSwiftDate( $ts, $format = TS::MW ) {
795 try {
796 $timestamp = new ConvertibleTimestamp( $ts );
797
798 return $timestamp->getTimestamp( $format );
799 } catch ( TimeoutException $e ) {
800 throw $e;
801 } catch ( Exception $e ) {
802 throw new FileBackendError( $e->getMessage() );
803 }
804 }
805
813 protected function addMissingHashMetadata( array $objHdrs, $path ) {
814 if ( isset( $objHdrs['x-object-meta-sha1base36'] ) ) {
815 return $objHdrs; // nothing to do
816 }
817
819 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
820 $this->logger->error( __METHOD__ . ": {path} was not stored with SHA-1 metadata.",
821 [ 'path' => $path ] );
822
823 $objHdrs['x-object-meta-sha1base36'] = false;
824
825 // Find prior custom HTTP headers
826 $postHeaders = $this->extractMutableContentHeaders( $objHdrs );
827 // Find prior metadata headers
828 $postHeaders += $this->extractMetadataHeaders( $objHdrs );
829
830 $status = $this->newStatus();
832 $scopeLockS = $this->getScopedFileLocks( [ $path ], LockManager::LOCK_UW, $status );
833 if ( $status->isOK() ) {
834 $tmpFile = $this->getLocalCopy( [ 'src' => $path, 'latest' => 1 ] );
835 if ( $tmpFile ) {
836 $hash = $tmpFile->getSha1Base36();
837 if ( $hash !== false ) {
838 $objHdrs['x-object-meta-sha1base36'] = $hash;
839 // Merge new SHA1 header into the old ones
840 $postHeaders['x-object-meta-sha1base36'] = $hash;
841 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
842 [ $rcode ] = $this->requestWithAuth( [
843 'method' => 'POST',
844 'container' => $srcCont,
845 'relPath' => $srcRel,
846 'headers' => $postHeaders
847 ] );
848 if ( $rcode >= 200 && $rcode <= 299 ) {
849 $this->deleteFileCache( $path );
850
851 return $objHdrs; // success
852 }
853 }
854 }
855 }
856
857 $this->logger->error( __METHOD__ . ': unable to set SHA-1 metadata for {path}',
858 [ 'path' => $path ] );
859
860 return $objHdrs; // failed
861 }
862
864 protected function doGetFileContentsMulti( array $params ) {
865 $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
866 // Blindly create tmp files and stream to them, catching any exception
867 // if the file does not exist. Do not waste time doing file stats here.
868 $reqs = []; // (path => op)
869
870 // Initial dummy values to preserve path order
871 $contents = array_fill_keys( $params['srcs'], self::RES_ERROR );
872 foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
873 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
874 if ( $srcRel === null ) {
875 continue; // invalid storage path
876 }
877 // Create a new temporary memory file...
878 $handle = fopen( 'php://temp', 'wb' );
879 if ( $handle ) {
880 $reqs[$path] = [
881 'method' => 'GET',
882 'container' => $srcCont,
883 'relPath' => $srcRel,
884 'headers' => $this->headersFromParams( $params ),
885 'stream' => $handle,
886 ];
887 }
888 }
889
890 $reqs = $this->requestMultiWithAuth(
891 $reqs,
892 [ 'maxConnsPerHost' => $params['concurrency'] ]
893 );
894 foreach ( $reqs as $path => $op ) {
895 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
896 if ( $rcode >= 200 && $rcode <= 299 ) {
897 rewind( $op['stream'] ); // start from the beginning
898 $content = (string)stream_get_contents( $op['stream'] );
899 $size = strlen( $content );
900 // Make sure that stream finished
901 if ( $size === (int)$rhdrs['content-length'] ) {
902 $contents[$path] = $content;
903 } else {
904 $contents[$path] = self::RES_ERROR;
905 $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
906 $this->onError( null, __METHOD__,
907 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
908 }
909 } elseif ( $rcode === 404 ) {
910 $contents[$path] = self::RES_ABSENT;
911 } else {
912 $contents[$path] = self::RES_ERROR;
913 $this->onError( null, __METHOD__,
914 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
915 }
916 fclose( $op['stream'] ); // close open handle
917 }
918
919 return $contents;
920 }
921
923 protected function doDirectoryExists( $fullCont, $dirRel, array $params ) {
924 $prefix = ( $dirRel == '' ) ? null : "{$dirRel}/";
925 $status = $this->objectListing( $fullCont, 'names', 1, null, $prefix );
926 if ( $status->isOK() ) {
927 return ( count( $status->value ) ) > 0;
928 }
929
930 return self::RES_ERROR;
931 }
932
940 public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
941 return new SwiftFileBackendDirList( $this, $fullCont, $dirRel, $params );
942 }
943
951 public function getFileListInternal( $fullCont, $dirRel, array $params ) {
952 return new SwiftFileBackendFileList( $this, $fullCont, $dirRel, $params );
953 }
954
966 public function getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
967 $dirs = [];
968 if ( $after === INF ) {
969 return $dirs; // nothing more
970 }
971
973 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
974
975 $prefix = ( $dir == '' ) ? null : "{$dir}/";
976 // Non-recursive: only list dirs right under $dir
977 if ( !empty( $params['topOnly'] ) ) {
978 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
979 if ( !$status->isOK() ) {
980 throw new FileBackendError( "Iterator page I/O error." );
981 }
982 $objects = $status->value;
983 foreach ( $objects as $object ) { // files and directories
984 if ( str_ends_with( $object, '/' ) ) {
985 $dirs[] = $object; // directories end in '/'
986 }
987 }
988 } else {
989 // Recursive: list all dirs under $dir and its subdirs
990 $getParentDir = static function ( $path ) {
991 return ( $path !== null && str_contains( $path, '/' ) ) ? dirname( $path ) : false;
992 };
993
994 // Get directory from last item of prior page
995 $lastDir = $getParentDir( $after ); // must be first page
996 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
997
998 if ( !$status->isOK() ) {
999 throw new FileBackendError( "Iterator page I/O error." );
1000 }
1001
1002 $objects = $status->value;
1003
1004 foreach ( $objects as $object ) { // files
1005 $objectDir = $getParentDir( $object ); // directory of object
1006
1007 if ( $objectDir !== false && $objectDir !== $dir ) {
1008 // Swift stores paths in UTF-8, using binary sorting.
1009 // See function "create_container_table" in common/db.py.
1010 // If a directory is not "greater" than the last one,
1011 // then it was already listed by the calling iterator.
1012 if ( strcmp( $objectDir, $lastDir ) > 0 ) {
1013 $pDir = $objectDir;
1014 do { // add dir and all its parent dirs
1015 $dirs[] = "{$pDir}/";
1016 $pDir = $getParentDir( $pDir );
1017 } while ( $pDir !== false
1018 && strcmp( $pDir, $lastDir ) > 0 // not done already
1019 && strlen( $pDir ) > strlen( $dir ) // within $dir
1020 );
1021 }
1022 $lastDir = $objectDir;
1023 }
1024 }
1025 }
1026 // Page on the unfiltered directory listing (what is returned may be filtered)
1027 if ( count( $objects ) < $limit ) {
1028 $after = INF; // avoid a second RTT
1029 } else {
1030 $after = end( $objects ); // update last item
1031 }
1032
1033 return $dirs;
1034 }
1035
1047 public function getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
1048 $files = []; // list of (path, stat map or null) entries
1049 if ( $after === INF ) {
1050 return $files; // nothing more
1051 }
1052
1054 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
1055
1056 $prefix = ( $dir == '' ) ? null : "{$dir}/";
1057 // $objects will contain a list of unfiltered names or stdClass items
1058 // Non-recursive: only list files right under $dir
1059 if ( !empty( $params['topOnly'] ) ) {
1060 if ( !empty( $params['adviseStat'] ) ) {
1061 $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix, '/' );
1062 } else {
1063 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
1064 }
1065 } else {
1066 // Recursive: list all files under $dir and its subdirs
1067 if ( !empty( $params['adviseStat'] ) ) {
1068 $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix );
1069 } else {
1070 $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
1071 }
1072 }
1073
1074 // Reformat this list into a list of (name, stat map or null) entries
1075 if ( !$status->isOK() ) {
1076 throw new FileBackendError( "Iterator page I/O error." );
1077 }
1078
1079 $objects = $status->value;
1080 $files = $this->buildFileObjectListing( $objects );
1081
1082 // Page on the unfiltered object listing (what is returned may be filtered)
1083 if ( count( $objects ) < $limit ) {
1084 $after = INF; // avoid a second RTT
1085 } else {
1086 $after = end( $objects ); // update last item
1087 $after = is_object( $after ) ? $after->name : $after;
1088 }
1089
1090 return $files;
1091 }
1092
1100 private function buildFileObjectListing( array $objects ) {
1101 $names = [];
1102 foreach ( $objects as $object ) {
1103 if ( is_object( $object ) ) {
1104 if ( isset( $object->subdir ) || !isset( $object->name ) ) {
1105 continue; // virtual directory entry; ignore
1106 }
1107 $stat = [
1108 // Convert various random Swift dates to TS::MW
1109 'mtime' => $this->convertSwiftDate( $object->last_modified, TS::MW ),
1110 'size' => (int)$object->bytes,
1111 'sha1' => null,
1112 // Note: manifest ETags are not an MD5 of the file
1113 'md5' => ctype_xdigit( $object->hash ) ? $object->hash : null,
1114 'latest' => false // eventually consistent
1115 ];
1116 $names[] = [ $object->name, $stat ];
1117 } elseif ( !str_ends_with( $object, '/' ) ) {
1118 // Omit directories, which end in '/' in listings
1119 $names[] = [ $object, null ];
1120 }
1121 }
1122
1123 return $names;
1124 }
1125
1132 public function loadListingStatInternal( $path, array $val ) {
1133 $this->cheapCache->setField( $path, 'stat', $val );
1134 }
1135
1137 protected function doGetFileXAttributes( array $params ) {
1138 $stat = $this->getFileStat( $params );
1139 // Stat entries filled by file listings don't include metadata/headers
1140 if ( is_array( $stat ) && !isset( $stat['xattr'] ) ) {
1141 $this->clearCache( [ $params['src'] ] );
1142 $stat = $this->getFileStat( $params );
1143 }
1144
1145 if ( is_array( $stat ) ) {
1146 return $stat['xattr'];
1147 }
1148
1149 return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1150 }
1151
1153 protected function doGetFileSha1base36( array $params ) {
1154 // Avoid using stat entries from file listings, which never include the SHA-1 hash.
1155 // Also, recompute the hash if it's not part of the metadata headers for some reason.
1156 $params['requireSHA1'] = true;
1157
1158 $stat = $this->getFileStat( $params );
1159 if ( is_array( $stat ) ) {
1160 return $stat['sha1'];
1161 }
1162
1163 return $stat === self::RES_ERROR ? self::RES_ERROR : self::RES_ABSENT;
1164 }
1165
1167 protected function doStreamFile( array $params ) {
1168 $status = $this->newStatus();
1169
1170 $flags = !empty( $params['headless'] ) ? HTTPFileStreamer::STREAM_HEADLESS : 0;
1171
1172 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1173 if ( $srcRel === null ) {
1174 HTTPFileStreamer::send404Message( $params['src'], $flags );
1175 $status->fatal( 'backend-fail-invalidpath', $params['src'] );
1176
1177 return $status;
1178 }
1179
1180 if ( !is_array( $this->getContainerStat( $srcCont ) ) ) {
1181 HTTPFileStreamer::send404Message( $params['src'], $flags );
1182 $status->fatal( 'backend-fail-stream', $params['src'] );
1183
1184 return $status;
1185 }
1186
1187 // If "headers" is set, we only want to send them if the file is there.
1188 // Do not bother checking if the file exists if headers are not set though.
1189 if ( $params['headers'] && !$this->fileExists( $params ) ) {
1190 HTTPFileStreamer::send404Message( $params['src'], $flags );
1191 $status->fatal( 'backend-fail-stream', $params['src'] );
1192
1193 return $status;
1194 }
1195
1196 // Send the requested additional headers
1197 if ( empty( $params['headless'] ) ) {
1198 foreach ( $params['headers'] as $header ) {
1199 $this->header( $header );
1200 }
1201 }
1202
1203 if ( empty( $params['allowOB'] ) ) {
1204 // Cancel output buffering and gzipping if set
1205 $this->resetOutputBuffer();
1206 }
1207
1208 $handle = fopen( 'php://output', 'wb' );
1209 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1210 'method' => 'GET',
1211 'container' => $srcCont,
1212 'relPath' => $srcRel,
1213 'headers' => $this->headersFromParams( $params ) + $params['options'],
1214 'stream' => $handle,
1215 'flags' => [ 'relayResponseHeaders' => empty( $params['headless'] ) ]
1216 ] );
1217
1218 if ( $rcode >= 200 && $rcode <= 299 ) {
1219 // good
1220 } elseif ( $rcode === 404 ) {
1221 $status->fatal( 'backend-fail-stream', $params['src'] );
1222 // Per T43113, nasty things can happen if bad cache entries get
1223 // stuck in cache. It's also possible that this error can come up
1224 // with simple race conditions. Clear out the stat cache to be safe.
1225 $this->clearCache( [ $params['src'] ] );
1226 $this->deleteFileCache( $params['src'] );
1227 } else {
1228 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1229 }
1230
1231 return $status;
1232 }
1233
1235 protected function doGetLocalCopyMulti( array $params ) {
1236 $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
1237 // Blindly create tmp files and stream to them, catching any exception
1238 // if the file does not exist. Do not waste time doing file stats here.
1239 $reqs = []; // (path => op)
1240
1241 // Initial dummy values to preserve path order
1242 $tmpFiles = array_fill_keys( $params['srcs'], self::RES_ERROR );
1243 foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
1244 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1245 if ( $srcRel === null ) {
1246 continue; // invalid storage path
1247 }
1248 // Get source file extension
1250 // Create a new temporary file...
1251 $tmpFile = $this->tmpFileFactory->newTempFSFile( 'localcopy_', $ext );
1252 $handle = $tmpFile ? fopen( $tmpFile->getPath(), 'wb' ) : false;
1253 if ( $handle ) {
1254 $reqs[$path] = [
1255 'method' => 'GET',
1256 'container' => $srcCont,
1257 'relPath' => $srcRel,
1258 'headers' => $this->headersFromParams( $params ),
1259 'stream' => $handle,
1260 ];
1261 $tmpFiles[$path] = $tmpFile;
1262 }
1263 }
1264
1265 // Ceph RADOS Gateway is in use (strong consistency) or X-Newest will be used
1266 $latest = ( $this->isRGW || !empty( $params['latest'] ) );
1267
1268 $reqs = $this->requestMultiWithAuth(
1269 $reqs,
1270 [ 'maxConnsPerHost' => $params['concurrency'] ]
1271 );
1272 foreach ( $reqs as $path => $op ) {
1273 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1274 fclose( $op['stream'] ); // close open handle
1275 if ( $rcode >= 200 && $rcode <= 299 ) {
1277 $tmpFile = $tmpFiles[$path];
1278 // Make sure that the stream finished and fully wrote to disk
1279 $size = $tmpFile->getSize();
1280 if ( $size !== (int)$rhdrs['content-length'] ) {
1281 $tmpFiles[$path] = self::RES_ERROR;
1282 $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
1283 $this->onError( null, __METHOD__,
1284 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
1285 }
1286 // Set the file stat process cache in passing
1287 $stat = $this->getStatFromHeaders( $rhdrs );
1288 $stat['latest'] = $latest;
1289 $this->cheapCache->setField( $path, 'stat', $stat );
1290 } elseif ( $rcode === 404 ) {
1291 $tmpFiles[$path] = self::RES_ABSENT;
1292 $this->cheapCache->setField(
1293 $path,
1294 'stat',
1295 $latest ? self::ABSENT_LATEST : self::ABSENT_NORMAL
1296 );
1297 } else {
1298 $tmpFiles[$path] = self::RES_ERROR;
1299 $this->onError( null, __METHOD__,
1300 [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc, $rbody );
1301 }
1302 }
1303
1304 return $tmpFiles;
1305 }
1306
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
1326 public function getFileHttpUrl( array $params ) {
1327 if ( $this->swiftTempUrlKey == '' &&
1328 ( $this->rgwS3AccessKey == '' || $this->rgwS3SecretKey != '' )
1329 ) {
1330 $this->logger->debug( "Can't get Swift file URL: no key available" );
1331 return self::TEMPURL_ERROR;
1332 }
1333
1334 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $params['src'] );
1335 if ( $srcRel === null ) {
1336 $this->logger->debug( "Can't get Swift file URL: can't resolve path" );
1337 return self::TEMPURL_ERROR; // invalid path
1338 }
1339
1340 $auth = $this->getAuthentication();
1341 if ( !$auth ) {
1342 $this->logger->debug( "Can't get Swift file URL: authentication failed" );
1343 return self::TEMPURL_ERROR;
1344 }
1345
1346 $method = $params['method'] ?? 'GET';
1347 $ttl = $params['ttl'] ?? 86400;
1348 $expires = time() + $ttl;
1349
1350 if ( $this->swiftTempUrlKey != '' ) {
1351 $url = $this->storageUrl( $auth, $srcCont, $srcRel );
1352 // Swift wants the signature based on the unencoded object name
1353 $contPath = parse_url( $this->storageUrl( $auth, $srcCont ), PHP_URL_PATH );
1354 $messageParts = [
1355 $method,
1356 $expires,
1357 "{$contPath}/{$srcRel}"
1358 ];
1359 $query = [
1360 'temp_url_expires' => $expires,
1361 ];
1362 if ( isset( $params['ipRange'] ) ) {
1363 array_unshift( $messageParts, "ip={$params['ipRange']}" );
1364 $query['temp_url_ip_range'] = $params['ipRange'];
1365 }
1366
1367 $signature = hash_hmac( 'sha1',
1368 implode( "\n", $messageParts ),
1369 $this->swiftTempUrlKey
1370 );
1371 $query = [ 'temp_url_sig' => $signature ] + $query;
1372
1373 return $url . '?' . http_build_query( $query );
1374 } else { // give S3 API URL for rgw
1375 // Path for signature starts with the bucket
1376 $spath = '/' . rawurlencode( $srcCont ) . '/' .
1377 str_replace( '%2F', '/', rawurlencode( $srcRel ) );
1378 // Calculate the hash
1379 $signature = base64_encode( hash_hmac(
1380 'sha1',
1381 "{$method}\n\n\n{$expires}\n{$spath}",
1382 $this->rgwS3SecretKey,
1383 true // raw
1384 ) );
1385 // See https://s3.amazonaws.com/doc/s3-developer-guide/RESTAuthentication.html.
1386 // Note: adding a newline for empty CanonicalizedAmzHeaders does not work.
1387 // Note: S3 API is the rgw default; remove the /swift/ URL bit.
1388 return str_replace( '/swift/v1', '', $this->storageUrl( $auth ) . $spath ) .
1389 '?' .
1390 http_build_query( [
1391 'Signature' => $signature,
1392 'Expires' => $expires,
1393 'AWSAccessKeyId' => $this->rgwS3AccessKey
1394 ] );
1395 }
1396 }
1397
1399 protected function directoriesAreVirtual() {
1400 return true;
1401 }
1402
1411 protected function headersFromParams( array $params ) {
1412 $hdrs = [];
1413 if ( !empty( $params['latest'] ) ) {
1414 $hdrs['x-newest'] = 'true';
1415 }
1416
1417 return $hdrs;
1418 }
1419
1421 protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
1423 '@phan-var SwiftFileOpHandle[] $fileOpHandles';
1424
1426 $statuses = [];
1427
1428 // Split the HTTP requests into stages that can be done concurrently
1429 $httpReqsByStage = []; // map of (stage => index => HTTP request)
1430 foreach ( $fileOpHandles as $index => $fileOpHandle ) {
1431 $reqs = $fileOpHandle->httpOp;
1432 foreach ( $reqs as $stage => $req ) {
1433 $httpReqsByStage[$stage][$index] = $req;
1434 }
1435 $statuses[$index] = $this->newStatus();
1436 }
1437
1438 // Run all requests for the first stage, then the next, and so on
1439 $reqCount = count( $httpReqsByStage );
1440 for ( $stage = 0; $stage < $reqCount; ++$stage ) {
1441 $httpReqs = $this->requestMultiWithAuth( $httpReqsByStage[$stage] );
1442 foreach ( $httpReqs as $index => $httpReq ) {
1444 $fileOpHandle = $fileOpHandles[$index];
1445 // Run the callback for each request of this operation
1446 $status = $statuses[$index];
1447 ( $fileOpHandle->callback )( $httpReq, $status );
1448 // On failure, abort all remaining requests for this operation. This is used
1449 // in "move" operations to abort the DELETE request if the PUT request fails.
1450 if (
1451 !$status->isOK() ||
1452 $fileOpHandle->state === $fileOpHandle::CONTINUE_NO
1453 ) {
1454 $stages = count( $fileOpHandle->httpOp );
1455 for ( $s = ( $stage + 1 ); $s < $stages; ++$s ) {
1456 unset( $httpReqsByStage[$s][$index] );
1457 }
1458 }
1459 }
1460 }
1461
1462 return $statuses;
1463 }
1464
1487 protected function setContainerAccess( $container, array $readUsers, array $writeUsers ) {
1488 $status = $this->newStatus();
1489
1490 [ $rcode, , , , ] = $this->requestWithAuth( [
1491 'method' => 'POST',
1492 'container' => $container,
1493 'headers' => [
1494 'x-container-read' => implode( ',', $readUsers ),
1495 'x-container-write' => implode( ',', $writeUsers )
1496 ]
1497 ] );
1498
1499 if ( $rcode != 204 && $rcode !== 202 ) {
1500 $status->fatal( 'backend-fail-internal', $this->name );
1501 $this->logger->error( __METHOD__ . ': unexpected rcode value ({rcode})',
1502 [ 'rcode' => $rcode ] );
1503 }
1504
1505 return $status;
1506 }
1507
1516 protected function getContainerStat( $container, $bypassCache = false ) {
1518 $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
1519
1520 if ( $bypassCache ) { // purge cache
1521 $this->containerStatCache->clear( $container );
1522 } elseif ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1523 $this->primeContainerCache( [ $container ] ); // check persistent cache
1524 }
1525 if ( !$this->containerStatCache->hasField( $container, 'stat' ) ) {
1526 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $this->requestWithAuth( [
1527 'method' => 'HEAD',
1528 'container' => $container
1529 ] );
1530
1531 if ( $rcode === 204 ) {
1532 $stat = [
1533 'count' => $rhdrs['x-container-object-count'],
1534 'bytes' => $rhdrs['x-container-bytes-used']
1535 ];
1536 if ( $bypassCache ) {
1537 return $stat;
1538 } else {
1539 $this->containerStatCache->setField( $container, 'stat', $stat ); // cache it
1540 $this->setContainerCache( $container, $stat ); // update persistent cache
1541 }
1542 } elseif ( $rcode === 404 ) {
1543 return self::RES_ABSENT;
1544 } else {
1545 $this->onError( null, __METHOD__,
1546 [ 'cont' => $container ], $rerr, $rcode, $rdesc, $rbody );
1547
1548 return self::RES_ERROR;
1549 }
1550 }
1551
1552 return $this->containerStatCache->getField( $container, 'stat' );
1553 }
1554
1562 protected function createContainer( $container, array $params ) {
1563 $status = $this->newStatus();
1564
1565 // @see SwiftFileBackend::setContainerAccess()
1566 if ( empty( $params['noAccess'] ) ) {
1567 // public
1568 $readUsers = array_merge( $this->readUsers, [ '.r:*', $this->swiftUser ] );
1569 if ( empty( $params['noListing'] ) ) {
1570 $readUsers[] = '.rlistings';
1571 }
1572 $writeUsers = array_merge( $this->writeUsers, [ $this->swiftUser ] );
1573 } else {
1574 // private
1575 $readUsers = array_merge( $this->secureReadUsers, [ $this->swiftUser ] );
1576 $writeUsers = array_merge( $this->secureWriteUsers, [ $this->swiftUser ] );
1577 }
1578
1579 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1580 'method' => 'PUT',
1581 'container' => $container,
1582 'headers' => [
1583 'x-container-read' => implode( ',', $readUsers ),
1584 'x-container-write' => implode( ',', $writeUsers )
1585 ]
1586 ] );
1587
1588 if ( $rcode === 201 ) { // new
1589 // good
1590 } elseif ( $rcode === 202 ) { // already there
1591 // this shouldn't really happen, but is OK
1592 } else {
1593 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1594 }
1595
1596 return $status;
1597 }
1598
1606 protected function deleteContainer( $container, array $params ) {
1607 $status = $this->newStatus();
1608
1609 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1610 'method' => 'DELETE',
1611 'container' => $container
1612 ] );
1613
1614 if ( $rcode >= 200 && $rcode <= 299 ) { // deleted
1615 $this->containerStatCache->clear( $container ); // purge
1616 } elseif ( $rcode === 404 ) { // not there
1617 // this shouldn't really happen, but is OK
1618 } elseif ( $rcode === 409 ) { // not empty
1619 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); // race?
1620 } else {
1621 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1622 }
1623
1624 return $status;
1625 }
1626
1639 private function objectListing(
1640 $fullCont, $type, $limit, $after = null, $prefix = null, $delim = null
1641 ) {
1642 $status = $this->newStatus();
1643
1644 $query = [ 'limit' => $limit ];
1645 if ( $type === 'info' ) {
1646 $query['format'] = 'json';
1647 }
1648 if ( $after !== null ) {
1649 $query['marker'] = $after;
1650 }
1651 if ( $prefix !== null ) {
1652 $query['prefix'] = $prefix;
1653 }
1654 if ( $delim !== null ) {
1655 $query['delimiter'] = $delim;
1656 }
1657
1658 [ $rcode, $rdesc, , $rbody, $rerr ] = $this->requestWithAuth( [
1659 'method' => 'GET',
1660 'container' => $fullCont,
1661 'query' => $query,
1662 ] );
1663
1664 $params = [ 'cont' => $fullCont, 'prefix' => $prefix, 'delim' => $delim ];
1665 if ( $rcode === 200 ) { // good
1666 if ( $type === 'info' ) {
1667 $status->value = json_decode( trim( $rbody ) );
1668 } else {
1669 $status->value = explode( "\n", trim( $rbody ) );
1670 }
1671 } elseif ( $rcode === 204 ) {
1672 $status->value = []; // empty container
1673 } elseif ( $rcode === 404 ) {
1674 $status->value = []; // no container
1675 } else {
1676 $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1677 }
1678
1679 return $status;
1680 }
1681
1683 protected function doPrimeContainerCache( array $containerInfo ) {
1684 foreach ( $containerInfo as $container => $info ) {
1685 $this->containerStatCache->setField( $container, 'stat', $info );
1686 }
1687 }
1688
1690 protected function doGetFileStatMulti( array $params ) {
1691 $stats = [];
1692
1693 $reqs = []; // (path => op)
1694 // (a) Check the containers of the paths...
1695 foreach ( $params['srcs'] as $path ) {
1696 [ $srcCont, $srcRel ] = $this->resolveStoragePathReal( $path );
1697 if ( $srcRel === null ) {
1698 // invalid storage path
1699 $stats[$path] = self::RES_ERROR;
1700 continue;
1701 }
1702
1703 $cstat = $this->getContainerStat( $srcCont );
1704 if ( $cstat === self::RES_ABSENT ) {
1705 $stats[$path] = self::RES_ABSENT;
1706 continue; // ok, nothing to do
1707 } elseif ( $cstat === self::RES_ERROR ) {
1708 $stats[$path] = self::RES_ERROR;
1709 continue;
1710 }
1711
1712 $reqs[$path] = [
1713 'method' => 'HEAD',
1714 'container' => $srcCont,
1715 'relPath' => $srcRel,
1716 'headers' => $this->headersFromParams( $params )
1717 ];
1718 }
1719
1720 // (b) Check the files themselves...
1721 $reqs = $this->requestMultiWithAuth(
1722 $reqs,
1723 [ 'maxConnsPerHost' => $params['concurrency'] ]
1724 );
1725 foreach ( $reqs as $path => $op ) {
1726 [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $op['response'];
1727 if ( $rcode === 200 || $rcode === 204 ) {
1728 // Update the object if it is missing some headers
1729 if ( !empty( $params['requireSHA1'] ) ) {
1730 $rhdrs = $this->addMissingHashMetadata( $rhdrs, $path );
1731 }
1732 // Load the stat map from the headers
1733 $stat = $this->getStatFromHeaders( $rhdrs );
1734 if ( $this->isRGW ) {
1735 $stat['latest'] = true; // strong consistency
1736 }
1737 } elseif ( $rcode === 404 ) {
1738 $stat = self::RES_ABSENT;
1739 } else {
1740 $stat = self::RES_ERROR;
1741 $this->onError( null, __METHOD__, $params, $rerr, $rcode, $rdesc, $rbody );
1742 }
1743 $stats[$path] = $stat;
1744 }
1745
1746 return $stats;
1747 }
1748
1753 protected function getStatFromHeaders( array $rhdrs ) {
1754 // Fetch all of the custom metadata headers
1755 $metadata = $this->getMetadataFromHeaders( $rhdrs );
1756 // Fetch all of the custom raw HTTP headers
1757 $headers = $this->extractMutableContentHeaders( $rhdrs );
1758
1759 return [
1760 // Convert various random Swift dates to TS::MW
1761 'mtime' => $this->convertSwiftDate( $rhdrs['last-modified'], TS::MW ),
1762 // Empty objects actually return no content-length header in Ceph
1763 'size' => isset( $rhdrs['content-length'] ) ? (int)$rhdrs['content-length'] : 0,
1764 'sha1' => $metadata['sha1base36'] ?? null,
1765 // Note: manifest ETags are not an MD5 of the file
1766 'md5' => ctype_xdigit( $rhdrs['etag'] ) ? $rhdrs['etag'] : null,
1767 'xattr' => [ 'metadata' => $metadata, 'headers' => $headers ]
1768 ];
1769 }
1770
1776 protected function getAuthentication() {
1777 if ( $this->authErrorTimestamp !== null ) {
1778 $interval = time() - $this->authErrorTimestamp;
1779 if ( $interval < 60 ) {
1780 $this->logger->debug(
1781 'rejecting request since auth failure occurred {interval} seconds ago',
1782 [ 'interval' => $interval ]
1783 );
1784 return null;
1785 } else { // actually retry this time
1786 $this->authErrorTimestamp = null;
1787 }
1788 }
1789 // Authenticate with proxy and get a session key...
1790 if ( !$this->authCreds ) {
1791 $cacheKey = $this->getCredsCacheKey( $this->swiftUser );
1792 $creds = $this->credentialCache->get( $cacheKey );
1793 if (
1794 isset( $creds['auth_token'] ) &&
1795 isset( $creds['storage_url'] ) &&
1796 isset( $creds['expiry_time'] ) &&
1797 $creds['expiry_time'] > time()
1798 ) {
1799 // Cache hit; reuse the cached credentials cache
1800 $this->setAuthCreds( $creds );
1801 } else {
1802 // Cache miss; re-authenticate to get the credentials
1803 $this->refreshAuthentication();
1804 }
1805 }
1806
1807 return $this->authCreds;
1808 }
1809
1815 private function setAuthCreds( ?array $creds ) {
1816 $this->logger->debug( 'Using auth token with expiry_time={expiry_time}',
1817 [
1818 'expiry_time' => isset( $creds['expiry_time'] )
1819 ? gmdate( 'c', $creds['expiry_time'] ) : 'null'
1820 ]
1821 );
1822 $this->authCreds = $creds;
1823 // Ceph RGW does not use <account> in URLs (OpenStack Swift uses "/v1/<account>")
1824 if ( $creds && str_ends_with( $creds['storage_url'], '/v1' ) ) {
1825 $this->isRGW = true; // take advantage of strong consistency in Ceph
1826 }
1827 }
1828
1834 private function refreshAuthentication() {
1835 [ $rcode, , $rhdrs, $rbody, ] = $this->http->run( [
1836 'method' => 'GET',
1837 'url' => "{$this->swiftAuthUrl}/v1.0",
1838 'headers' => [
1839 'x-auth-user' => $this->swiftUser,
1840 'x-auth-key' => $this->swiftKey
1841 ]
1842 ], self::DEFAULT_HTTP_OPTIONS );
1843
1844 if ( $rcode >= 200 && $rcode <= 299 ) { // OK
1845 if ( isset( $rhdrs['x-auth-token-expires'] ) ) {
1846 $ttl = intval( $rhdrs['x-auth-token-expires'] );
1847 } else {
1848 $ttl = $this->authTTL;
1849 }
1850 $expiryTime = time() + $ttl;
1851 $creds = [
1852 'auth_token' => $rhdrs['x-auth-token'],
1853 'storage_url' => $this->swiftStorageUrl ?? $rhdrs['x-storage-url'],
1854 'expiry_time' => $expiryTime,
1855 ];
1856 $this->credentialCache->set(
1857 $this->getCredsCacheKey( $this->swiftUser ),
1858 $creds,
1859 $expiryTime
1860 );
1861 } elseif ( $rcode === 401 ) {
1862 $this->onError( null, __METHOD__, [], "Authentication failed.", $rcode );
1863 $this->authErrorTimestamp = time();
1864 $creds = null;
1865 } else {
1866 $this->onError( null, __METHOD__, [], "HTTP return code: $rcode", $rcode, $rbody );
1867 $this->authErrorTimestamp = time();
1868 $creds = null;
1869 }
1870 $this->setAuthCreds( $creds );
1871 return $creds;
1872 }
1873
1880 protected function storageUrl( array $creds, $container = null, $object = null ) {
1881 $parts = [ $creds['storage_url'] ];
1882 if ( ( $container ?? '' ) !== '' ) {
1883 $parts[] = rawurlencode( $container );
1884 }
1885 if ( ( $object ?? '' ) !== '' ) {
1886 $parts[] = str_replace( "%2F", "/", rawurlencode( $object ) );
1887 }
1888
1889 return implode( '/', $parts );
1890 }
1891
1896 protected function authTokenHeaders( array $creds ) {
1897 return [ 'x-auth-token' => $creds['auth_token'] ];
1898 }
1899
1906 private function getCredsCacheKey( $username ) {
1907 return 'swiftcredentials:' . md5( $username . ':' . $this->swiftAuthUrl );
1908 }
1909
1922 private function requestWithAuth( array $req ) {
1923 return $this->requestMultiWithAuth( [ $req ] )[0]['response'];
1924 }
1925
1935 private function requestMultiWithAuth( array $reqs, $options = [] ) {
1936 $remainingTries = 2;
1937 $auth = $this->getAuthentication();
1938 while ( true ) {
1939 if ( !$auth ) {
1940 foreach ( $reqs as &$req ) {
1941 if ( !isset( $req['response'] ) ) {
1942 $req['response'] = $this->getAuthFailureResponse();
1943 }
1944 }
1945 break;
1946 }
1947 foreach ( $reqs as &$req ) {
1948 '@phan-var array $req'; // Not array[]
1949 if ( isset( $req['response'] ) ) {
1950 // Request was attempted before
1951 // Retry only if it gave a 401 response code
1952 if ( $req['response']['code'] !== 401 ) {
1953 continue;
1954 }
1955 }
1956 $req['headers'] = $this->authTokenHeaders( $auth ) + ( $req['headers'] ?? [] );
1957 $req['url'] = $this->storageUrl( $auth, $req['container'], $req['relPath'] ?? null );
1958 }
1959 unset( $req );
1960 $reqs = $this->http->runMulti( $reqs, $options + self::DEFAULT_HTTP_OPTIONS );
1961 if ( --$remainingTries > 0 ) {
1962 // Retry if any request failed with 401 "not authorized"
1963 foreach ( $reqs as $req ) {
1964 if ( $req['response']['code'] === 401 ) {
1965 $auth = $this->refreshAuthentication();
1966 continue 2;
1967 }
1968 }
1969 }
1970 break;
1971 }
1972 return $reqs;
1973 }
1974
1983 private function getAuthFailureResponse() {
1984 return [
1985 'code' => 0,
1986 0 => 0,
1987 'reason' => '',
1988 1 => '',
1989 'headers' => [],
1990 2 => [],
1991 'body' => '',
1992 3 => '',
1993 'error' => self::AUTH_FAILURE_ERROR,
1994 4 => self::AUTH_FAILURE_ERROR
1995 ];
1996 }
1997
2010 public function onError( $status, $func, array $params, $err = '', $code = 0, $desc = '', $body = '' ) {
2011 if ( $code === 0 && $err === self::AUTH_FAILURE_ERROR ) {
2012 if ( $status instanceof StatusValue ) {
2013 $status->fatal( 'backend-fail-connect', $this->name );
2014 }
2015 // Already logged
2016 return;
2017 }
2018 if ( $status instanceof StatusValue ) {
2019 $status->fatal( 'backend-fail-internal', $this->name );
2020 }
2021 $msg = "HTTP {code} ({desc}) in '{func}'";
2022 $msgParams = [
2023 'code' => $code,
2024 'desc' => $desc,
2025 'func' => $func,
2026 'req_params' => $params,
2027 ];
2028 if ( $err ) {
2029 $msg .= ': {err}';
2030 $msgParams['err'] = $err;
2031 }
2032 if ( $code == 502 ) {
2033 $msg .= ' ({truncatedBody})';
2034 $msgParams['truncatedBody'] = substr( strip_tags( $body ), 0, 100 );
2035 }
2036 $this->logger->error( $msg, $msgParams );
2037 }
2038}
2039
2041class_alias( SwiftFileBackend::class, 'SwiftFileBackend' );
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...
convertSwiftDate( $ts, $format=TS::MW)
Convert dates like "Tue, 03 Jan 2012 22:01:04 GMT"/"2013-05-11T07:37:27.678360Z".
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.
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.
Resource locking handling.
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.