class StageBase

Creates and manages a stage directory in which to install or update code.

Allows calling code to copy the current Drupal site into a temporary stage directory, use Composer to require packages into it, sync changes from the stage directory back into the active code base, and then delete the stage directory.

Only one stage directory can exist at any given time, and the stage is owned by the user or session that originally created it. Only the owner can perform operations on the stage directory, and the stage must be "claimed" by its owner before any such operations are done. A stage is claimed by presenting a unique token that is generated when the stage is created.

Although a site can only have one stage directory, it is possible for privileged users to destroy a stage created by another user. To prevent such actions from putting the file system into an uncertain state (for example, if a stage is destroyed by another user while it is still being created), the stage directory has a randomly generated name. For additional cleanliness, all stage directories created by a specific site live in a single directory ,called the "stage root directory" and identified by the UUID of the current site (e.g. `/tmp/.package_managerSITE_UUID`), which is deleted when any stage created by that site is destroyed.

Hierarchy

Expanded class hierarchy of StageBase

12 files declare their use of StageBase
ApiController.php in core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
CollectPathsToExcludeEvent.php in core/modules/package_manager/src/Event/CollectPathsToExcludeEvent.php
FailureMarkerRequirementTest.php in core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php
PackageManagerKernelTestBase.php in core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
PreApplyEvent.php in core/modules/package_manager/src/Event/PreApplyEvent.php

... See full list

File

core/modules/package_manager/src/StageBase.php, line 64

Namespace

Drupal\package_manager
View source
abstract class StageBase implements LoggerAwareInterface {
    use LoggerAwareTrait;
    use StringTranslationTrait;
    
    /**
     * The tempstore key under which to store the locking info for this stage.
     *
     * @var string
     */
    protected final const TEMPSTORE_LOCK_KEY = 'lock';
    
    /**
     * The tempstore key under which to store arbitrary metadata for this stage.
     *
     * @var string
     */
    protected final const TEMPSTORE_METADATA_KEY = 'metadata';
    
    /**
     * The tempstore key under which to store the path of stage root directory.
     *
     * @var string
     *
     * @see ::getStagingRoot()
     */
    private const TEMPSTORE_STAGING_ROOT_KEY = 'staging_root';
    
    /**
     * The tempstore key under which to store the time that ::apply() was called.
     *
     * @var string
     *
     * @see ::apply()
     * @see ::destroy()
     */
    private const TEMPSTORE_APPLY_TIME_KEY = 'apply_time';
    
    /**
     * The tempstore key for whether staged operations have been applied.
     *
     * @var string
     *
     * @see ::apply()
     * @see ::destroy()
     */
    private const TEMPSTORE_CHANGES_APPLIED = 'changes_applied';
    
    /**
     * The tempstore key for information about previously destroyed stages.
     *
     * @var string
     *
     * @see ::apply()
     * @see ::destroy()
     */
    private const TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX = 'TEMPSTORE_DESTROYED_STAGES_INFO';
    
    /**
     * The regular expression to check if a package name is a platform package.
     *
     * @var string
     *
     * @see \Composer\Repository\PlatformRepository::PLATFORM_PACKAGE_REGEX
     * @see ::validateRequirements()
     */
    private const COMPOSER_PLATFORM_PACKAGE_REGEX = '{^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$}iD';
    
    /**
     * The regular expression to check if a package name is a regular package.
     *
     * If you try to require an invalid package name, this is the regular
     * expression that Composer will, at the command line, tell you to match.
     *
     * @var string
     *
     * @see \Composer\Package\Loader\ValidatingArrayLoader::hasPackageNamingError()
     * @see ::validateRequirements()
     */
    private const COMPOSER_PACKAGE_REGEX = '/^[a-z0-9]([_.-]?[a-z0-9]+)*\\/[a-z0-9](([_.]?|-{0,2})[a-z0-9]+)*$/';
    
    /**
     * The lock info for the stage.
     *
     * Consists of a unique random string and the current class name.
     *
     * @var string[]
     */
    private $lock;
    
    /**
     * The shared temp store.
     *
     * @var \Drupal\Core\TempStore\SharedTempStore
     */
    protected SharedTempStore $tempStore;
    
    /**
     * The stage type.
     *
     * To ensure that stage classes do not unintentionally use another stage's
     * type, all concrete subclasses MUST explicitly define this property.
     * The recommended pattern is `MODULE:TYPE`.
     *
     * @var string
     */
    protected string $type;
    public function __construct(PathLocator $pathLocator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, QueueFactory $queueFactory, EventDispatcherInterface $eventDispatcher, SharedTempStoreFactory $tempStoreFactory, TimeInterface $time, PathFactoryInterface $pathFactory, FailureMarker $failureMarker) {
        $this->tempStore = $tempStoreFactory->get('package_manager_stage');
    }
    
    /**
     * Gets the stage type.
     *
     * The stage type can be used by stage event subscribers to implement logic
     * specific to certain stages, without relying on the class name (which may
     * not be part of module's public API).
     *
     * @return string
     *   The stage type.
     *
     * @throws \LogicException
     *   Thrown if $this->type is not explicitly overridden.
     */
    public final function getType() : string {
        $reflector = new \ReflectionProperty($this, 'type');
        // The $type property must ALWAYS be overridden. This means that different
        // subclasses can return the same value (thus allowing one stage to
        // impersonate another one), but if that happens, it is intentional.
        if ($reflector->getDeclaringClass()
            ->getName() === static::class) {
            return $this->type;
        }
        throw new \LogicException(static::class . ' must explicitly override the $type property.');
    }
    
    /**
     * Determines if the stage directory can be created.
     *
     * @return bool
     *   TRUE if the stage directory can be created, otherwise FALSE.
     */
    public final function isAvailable() : bool {
        return empty($this->tempStore
            ->getMetadata(static::TEMPSTORE_LOCK_KEY));
    }
    
    /**
     * Returns a specific piece of metadata associated with this stage.
     *
     * Only the owner of the stage can access metadata, and the stage must either
     * be claimed by its owner, or created during the current request.
     *
     * @param string $key
     *   The metadata key.
     *
     * @return mixed
     *   The metadata value, or NULL if it is not set.
     */
    public function getMetadata(string $key) {
        $this->checkOwnership();
        $metadata = $this->tempStore
            ->get(static::TEMPSTORE_METADATA_KEY) ?: [];
        return $metadata[$key] ?? NULL;
    }
    
    /**
     * Stores arbitrary metadata associated with this stage.
     *
     * Only the owner of the stage can set metadata, and the stage must either be
     * claimed by its owner, or created during the current request.
     *
     * @param string $key
     *   The key under which to store the metadata. To prevent conflicts, it is
     *   strongly recommended that this be prefixed with the name of the module
     *   storing the data.
     * @param mixed $data
     *   The metadata to store.
     */
    public function setMetadata(string $key, $data) : void {
        $this->checkOwnership();
        $metadata = $this->tempStore
            ->get(static::TEMPSTORE_METADATA_KEY);
        $metadata[$key] = $data;
        $this->tempStore
            ->set(static::TEMPSTORE_METADATA_KEY, $metadata);
    }
    
    /**
     * Collects paths that Composer Stager should exclude.
     *
     * @return \PhpTuf\ComposerStager\API\Path\Value\PathListInterface
     *   A list of paths that Composer Stager should exclude when creating the
     *   stage directory and applying staged changes to the active directory.
     *
     * @throws \Drupal\package_manager\Exception\StageException
     *   Thrown if an exception occurs while collecting paths to exclude.
     *
     * @see ::create()
     * @see ::apply()
     */
    protected function getPathsToExclude() : PathListInterface {
        $event = new CollectPathsToExcludeEvent($this, $this->pathLocator, $this->pathFactory);
        try {
            return $this->eventDispatcher
                ->dispatch($event);
        } catch (\Throwable $e) {
            $this->rethrowAsStageException($e);
        }
    }
    
    /**
     * Copies the active code base into the stage directory.
     *
     * This will automatically claim the stage, so external code does NOT need to
     * call ::claim(). However, if it was created during another request, the
     * stage must be claimed before operations can be performed on it.
     *
     * @param int|null $timeout
     *   (optional) How long to allow the file copying operation to run before
     *   timing out, in seconds, or NULL to never time out. Defaults to 300
     *   seconds.
     *
     * @return string
     *   Unique ID for the stage, which can be used to claim the stage before
     *   performing other operations on it. Calling code should store this ID for
     *   as long as the stage needs to exist.
     *
     * @throws \Drupal\package_manager\Exception\StageException
     *   Thrown if a stage directory already exists, or if an error occurs while
     *   creating the stage directory. In the latter situation, the stage
     *   directory will be destroyed.
     *
     * @see ::claim()
     */
    public function create(?int $timeout = 300) : string {
        $this->failureMarker
            ->assertNotExists();
        if (!$this->isAvailable()) {
            throw new StageException($this, 'Cannot create a new stage because one already exists.');
        }
        // Mark the stage as unavailable as early as possible, before dispatching
        // the pre-create event. The idea is to prevent a race condition if the
        // event subscribers take a while to finish, and two different users attempt
        // to create a stage directory at around the same time. If an error occurs
        // while the event is being processed, the stage is marked as available.
        // @see ::dispatch()
        // We specifically generate a random 32-character alphanumeric name in order
        // to guarantee that the stage ID won't start with -, which could cause it
        // to be interpreted as an option if it's used as a command-line argument.
        // (For example, \Drupal\Component\Utility\Crypt::randomBytesBase64() would
        // be vulnerable to this; the stage ID needs to be unique, but not
        // cryptographically so.)
        $id = (new Random())->name(32);
        // Re-acquire the tempstore to ensure that the lock is written by whoever is
        // actually logged in (or not) right now, since it's possible that the stage
        // was instantiated (i.e., __construct() was called) by a different session,
        // which would result in the lock having the wrong owner and the stage not
        // being claimable by whoever is actually creating it.
        $this->tempStore = $this->tempStoreFactory
            ->get('package_manager_stage');
        // For the lock value, we use both the stage's class and its type in order
        // to prevent a stage from being manipulated by two different classes during
        // a single life cycle.
        $this->tempStore
            ->set(static::TEMPSTORE_LOCK_KEY, [
            $id,
            static::class,
            $this->getType(),
        ]);
        $this->claim($id);
        $active_dir = $this->pathFactory
            ->create($this->pathLocator
            ->getProjectRoot());
        $stage_dir = $this->pathFactory
            ->create($this->getStageDirectory());
        $excluded_paths = $this->getPathsToExclude();
        $event = new PreCreateEvent($this, $excluded_paths);
        // If an error occurs and we won't be able to create the stage, mark it as
        // available.
        $this->dispatch($event, [
            $this,
            'markAsAvailable',
        ]);
        try {
            $this->beginner
                ->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout);
        } catch (\Throwable $error) {
            $this->destroy();
            $this->rethrowAsStageException($error);
        }
        $this->dispatch(new PostCreateEvent($this));
        return $id;
    }
    
    /**
     * Wraps an exception in a StageException and re-throws it.
     *
     * @param \Throwable $e
     *   The throwable to wrap.
     */
    private function rethrowAsStageException(\Throwable $e) : never {
        throw new StageException($this, $e->getMessage(), $e->getCode(), $e);
    }
    
    /**
     * Adds or updates packages in the stage directory.
     *
     * @param string[] $runtime
     *   The packages to add as regular top-level dependencies, in the form
     *   'vendor/name' or 'vendor/name:version'.
     * @param string[] $dev
     *   (optional) The packages to add as dev dependencies, in the form
     *   'vendor/name' or 'vendor/name:version'. Defaults to an empty array.
     * @param int|null $timeout
     *   (optional) How long to allow the Composer operation to run before timing
     *   out, in seconds, or NULL to never time out. Defaults to 300 seconds.
     *
     * @throws \Drupal\package_manager\Exception\StageException
     *   Thrown if the Composer operation cannot be started, or if an error occurs
     *   during the operation. In the latter situation, the stage directory will
     *   be destroyed.
     */
    public function require(array $runtime, array $dev = [], ?int $timeout = 300) : void {
        $this->checkOwnership();
        $this->dispatch(new PreRequireEvent($this, $runtime, $dev));
        // A helper function to execute a command in the stage, destroying it if an
        // exception occurs in the middle of a Composer operation.
        $do_stage = function (array $command) use ($timeout) : void {
            $active_dir = $this->pathFactory
                ->create($this->pathLocator
                ->getProjectRoot());
            $stage_dir = $this->pathFactory
                ->create($this->getStageDirectory());
            try {
                $this->stager
                    ->stage($command, $active_dir, $stage_dir, NULL, $timeout);
            } catch (\Throwable $e) {
                // If the caught exception isn't InvalidArgumentException or
                // PreconditionException, a Composer operation was actually attempted,
                // and failed. The stage should therefore be destroyed, because it's in
                // an indeterminate and possibly unrecoverable state.
                if (!$e instanceof InvalidArgumentException && !$e instanceof PreconditionException) {
                    $this->destroy();
                }
                $this->rethrowAsStageException($e);
            }
        };
        // Change the runtime and dev requirements as needed, but don't update
        // the installed packages yet.
        if ($runtime) {
            self::validateRequirements($runtime);
            $command = array_merge([
                'require',
                '--no-update',
            ], $runtime);
            $do_stage($command);
        }
        if ($dev) {
            self::validateRequirements($dev);
            $command = array_merge([
                'require',
                '--dev',
                '--no-update',
            ], $dev);
            $do_stage($command);
        }
        // If constraints were changed, update those packages.
        if ($runtime || $dev) {
            $command = array_merge([
                'update',
                '--with-all-dependencies',
                '--optimize-autoloader',
            ], $runtime, $dev);
            $do_stage($command);
        }
        $this->dispatch(new PostRequireEvent($this, $runtime, $dev));
    }
    
    /**
     * Applies staged changes to the active directory.
     *
     * After the staged changes are applied, the current request should be
     * terminated as soon as possible. This is because the code loaded into the
     * PHP runtime may no longer match the code that is physically present in the
     * active code base, which means that the current request is running in an
     * unreliable, inconsistent environment. In the next request,
     * ::postApply() should be called as early as possible after Drupal is
     * fully bootstrapped, to rebuild the service container, flush caches, and
     * dispatch the post-apply event.
     *
     * @param int|null $timeout
     *   (optional) How long to allow the file copying operation to run before
     *   timing out, in seconds, or NULL to never time out. Defaults to 600
     *   seconds.
     *
     * @throws \Drupal\package_manager\Exception\ApplyFailedException
     *   Thrown if there is an error calling Composer Stager, which may indicate
     *   a failed commit operation.
     */
    public function apply(?int $timeout = 600) : void {
        $this->checkOwnership();
        $active_dir = $this->pathFactory
            ->create($this->pathLocator
            ->getProjectRoot());
        $stage_dir = $this->pathFactory
            ->create($this->getStageDirectory());
        $excluded_paths = $this->getPathsToExclude();
        $event = new PreApplyEvent($this, $excluded_paths);
        // If an error occurs while dispatching the events, ensure that ::destroy()
        // doesn't think we're in the middle of applying the staged changes to the
        // active directory.
        $this->tempStore
            ->set(self::TEMPSTORE_APPLY_TIME_KEY, $this->time
            ->getRequestTime());
        $this->dispatch($event, $this->setNotApplying(...));
        // Create a marker file so that we can tell later on if the commit failed.
        $this->failureMarker
            ->write($this, $this->getFailureMarkerMessage());
        try {
            $this->committer
                ->commit($stage_dir, $active_dir, $excluded_paths, NULL, $timeout);
        } catch (InvalidArgumentException|PreconditionException $e) {
            // The commit operation has not started yet, so we can clear the failure
            // marker and release the flag that says we're applying.
            $this->setNotApplying();
            $this->failureMarker
                ->clear();
            $this->rethrowAsStageException($e);
        } catch (\Throwable $throwable) {
            // The commit operation may have failed midway through, and the site code
            // is in an indeterminate state. Release the flag which says we're still
            // applying, because in this situation, the site owner should probably
            // restore everything from a backup.
            $this->setNotApplying();
            // Update the marker file with the information from the throwable.
            $this->failureMarker
                ->write($this, $this->getFailureMarkerMessage(), $throwable);
            throw new ApplyFailedException($this, $this->failureMarker
                ->getMessage(), $throwable->getCode(), $throwable);
        }
        $this->failureMarker
            ->clear();
        $this->setMetadata(self::TEMPSTORE_CHANGES_APPLIED, TRUE);
    }
    
    /**
     * Returns a closure that marks this stage as no longer being applied.
     */
    private function setNotApplying() : void {
        $this->tempStore
            ->delete(self::TEMPSTORE_APPLY_TIME_KEY);
    }
    
    /**
     * Performs post-apply tasks.
     *
     * This should be called as soon as possible after ::apply(), in a new
     * request.
     *
     * @see ::apply()
     */
    public function postApply() : void {
        $this->checkOwnership();
        if ($this->tempStore
            ->get(self::TEMPSTORE_APPLY_TIME_KEY) === $this->time
            ->getRequestTime()) {
            $this->logger?->warning('Post-apply tasks are running in the same request during which staged changes were applied to the active code base. This can result in unpredictable behavior.');
        }
        // Rebuild the container and clear all caches, to ensure that new services
        // are picked up.
        drupal_flush_all_caches();
        // Refresh the event dispatcher so that new or changed event subscribers
        // will be called. The other services we depend on are either stateless or
        // unlikely to call newly added code during the current request.
        $this->eventDispatcher = \Drupal::service('event_dispatcher');
        $release_apply = $this->setNotApplying(...);
        $this->dispatch(new PostApplyEvent($this), $release_apply);
        $release_apply();
    }
    
    /**
     * Deletes the stage directory.
     *
     * @param bool $force
     *   (optional) If TRUE, the stage directory will be destroyed even if it is
     *   not owned by the current user or session. Defaults to FALSE.
     * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message
     *   (optional) A message about why the stage was destroyed.
     *
     * @throws \Drupal\package_manager\Exception\StageException
     *   If the staged changes are being applied to the active directory.
     * @throws \Drupal\Core\TempStore\TempStoreException
     */
    public function destroy(bool $force = FALSE, ?TranslatableMarkup $message = NULL) : void {
        if (!$force) {
            $this->checkOwnership();
        }
        if ($this->isApplying()) {
            throw new StageException($this, 'Cannot destroy the stage directory while it is being applied to the active directory.');
        }
        // If the stage directory exists, queue it to be automatically cleaned up
        // later by a queue (which may or may not happen during cron).
        // @see \Drupal\package_manager\Plugin\QueueWorker\Cleaner
        if ($this->stageDirectoryExists()) {
            $this->queueFactory
                ->get('package_manager_cleanup')
                ->createItem($this->getStageDirectory());
        }
        $this->storeDestroyInfo($force, $message);
        $this->markAsAvailable();
    }
    
    /**
     * Marks the stage as available.
     */
    protected function markAsAvailable() : void {
        $this->tempStore
            ->delete(static::TEMPSTORE_METADATA_KEY);
        $this->tempStore
            ->delete(static::TEMPSTORE_LOCK_KEY);
        $this->tempStore
            ->delete(self::TEMPSTORE_STAGING_ROOT_KEY);
        $this->lock = NULL;
    }
    
    /**
     * Dispatches an event and handles any errors that it collects.
     *
     * @param \Drupal\package_manager\Event\StageEvent $event
     *   The event object.
     * @param callable|null $on_error
     *   (optional) A callback function to call if an error occurs, before any
     *   exceptions are thrown.
     *
     * @throws \Drupal\package_manager\Exception\StageEventException
     *   If the event collects any validation errors.
     */
    protected function dispatch(StageEvent $event, ?callable $on_error = NULL) : void {
        try {
            $this->eventDispatcher
                ->dispatch($event);
            if ($event instanceof PreOperationStageEvent) {
                if ($event->getResults()) {
                    $error = new StageEventException($event);
                }
            }
        } catch (\Throwable $error) {
            $error = new StageEventException($event, $error->getMessage(), $error->getCode(), $error);
        }
        if (isset($error)) {
            // Ensure the error is logged for post-mortem diagnostics.
            if ($this->logger) {
                Error::logException($this->logger, $error);
            }
            if ($on_error) {
                $on_error();
            }
            throw $error;
        }
    }
    
    /**
     * Attempts to claim the stage.
     *
     * Once a stage has been created, no operations can be performed on it until
     * it is claimed. This is to ensure that stage operations across multiple
     * requests are being done by the same code, running under the same user or
     * session that created the stage in the first place. To claim a stage, the
     * calling code must provide the unique identifier that was generated when the
     * stage was created.
     *
     * The stage is claimed when it is created, so external code does NOT need to
     * call this method after calling ::create() in the same request.
     *
     * @param string $unique_id
     *   The unique ID that was returned by ::create().
     *
     * @return $this
     *
     * @throws \Drupal\package_manager\Exception\StageOwnershipException
     *   If the stage cannot be claimed. This can happen if the current user or
     *   session did not originally create the stage, if $unique_id doesn't match
     *   the unique ID that was generated when the stage was created, or the
     *   current class is not the same one that was used to create the stage.
     *
     * @see ::create()
     */
    public final function claim(string $unique_id) : self {
        $this->failureMarker
            ->assertNotExists();
        if ($this->isAvailable()) {
            // phpcs:disable DrupalPractice.General.ExceptionT.ExceptionT
            // @see https://www.drupal.org/project/auto_updates/issues/3338651
            throw new StageException($this, $this->computeDestroyMessage($unique_id, $this->t('Cannot claim the stage because no stage has been created.'))
                ->render());
        }
        $stored_lock = $this->tempStore
            ->getIfOwner(static::TEMPSTORE_LOCK_KEY);
        if (!$stored_lock) {
            throw new StageOwnershipException($this, $this->computeDestroyMessage($unique_id, $this->t('Cannot claim the stage because it is not owned by the current user or session.'))
                ->render());
        }
        if ($stored_lock === [
            $unique_id,
            static::class,
            $this->getType(),
        ]) {
            $this->lock = $stored_lock;
            return $this;
        }
        throw new StageOwnershipException($this, $this->computeDestroyMessage($unique_id, $this->t('Cannot claim the stage because the current lock does not match the stored lock.'))
            ->render());
        // phpcs:enable DrupalPractice.General.ExceptionT.ExceptionT
    }
    
    /**
     * Returns the specific destroy message for the ID.
     *
     * @param string $unique_id
     *   The unique ID that was returned by ::create().
     * @param \Drupal\Core\StringTranslation\TranslatableMarkup $fallback_message
     *   A fallback message, in case no specific message was stored.
     *
     * @return \Drupal\Core\StringTranslation\TranslatableMarkup
     *   A message describing why the stage with the given ID was destroyed, or if
     *   no message was associated with that destroyed stage, the provided
     *   fallback message.
     */
    private function computeDestroyMessage(string $unique_id, TranslatableMarkup $fallback_message) : TranslatableMarkup {
        // Check to see if we have a specific message about a stage with a
        // specific ID that was given.
        return $this->tempStore
            ->get(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $unique_id) ?? $fallback_message;
    }
    
    /**
     * Validates the ownership of stage directory.
     *
     * The stage is considered under valid ownership if it was created by current
     * user or session, using the current class.
     *
     * @throws \LogicException
     *   If ::claim() has not been previously called.
     * @throws \Drupal\package_manager\Exception\StageOwnershipException
     *   If the current user or session does not own the stage directory, or it
     *   was created by a different class.
     */
    protected final function checkOwnership() : void {
        if (empty($this->lock)) {
            throw new \LogicException('Stage must be claimed before performing any operations on it.');
        }
        $stored_lock = $this->tempStore
            ->getIfOwner(static::TEMPSTORE_LOCK_KEY);
        if ($stored_lock !== $this->lock) {
            throw new StageOwnershipException($this, 'Stage is not owned by the current user or session.');
        }
    }
    
    /**
     * Returns the path of the directory where changes should be staged.
     *
     * @return string
     *   The absolute path of the directory where changes should be staged.
     *
     * @throws \LogicException
     *   If this method is called before the stage has been created or claimed.
     */
    public function getStageDirectory() : string {
        if (!$this->lock) {
            throw new \LogicException(__METHOD__ . '() cannot be called because the stage has not been created or claimed.');
        }
        return $this->getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0];
    }
    
    /**
     * Returns the directory where stage directories will be created.
     *
     * @return string
     *   The absolute path of the directory containing the stage directories
     *   managed by this class.
     */
    private function getStagingRoot() : string {
        // Since the stage root can depend on site settings, store it so that
        // things won't break if the settings change during this stage's life
        // cycle.
        $dir = $this->tempStore
            ->get(self::TEMPSTORE_STAGING_ROOT_KEY);
        if (empty($dir)) {
            $dir = $this->pathLocator
                ->getStagingRoot();
            $this->tempStore
                ->set(self::TEMPSTORE_STAGING_ROOT_KEY, $dir);
        }
        return $dir;
    }
    
    /**
     * Determines if the stage directory exists.
     *
     * @return bool
     *   TRUE if the directory exists, otherwise FALSE.
     */
    public function stageDirectoryExists() : bool {
        try {
            return is_dir($this->getStageDirectory());
        } catch (\LogicException) {
            return FALSE;
        }
    }
    
    /**
     * Checks if staged changes are being applied to the active directory.
     *
     * @return bool
     *   TRUE if the staged changes are being applied to the active directory, and
     *   it has been less than an hour since that operation began. If more than an
     *   hour has elapsed since the changes started to be applied, FALSE is
     *   returned even if the stage internally thinks that changes are still being
     *   applied.
     *
     * @see ::apply()
     */
    public final function isApplying() : bool {
        $apply_time = $this->tempStore
            ->get(self::TEMPSTORE_APPLY_TIME_KEY);
        return isset($apply_time) && $this->time
            ->getRequestTime() - $apply_time < 3600;
    }
    
    /**
     * Returns the failure marker message.
     *
     * @return \Drupal\Core\StringTranslation\TranslatableMarkup
     *   The translated failure marker message.
     */
    protected function getFailureMarkerMessage() : TranslatableMarkup {
        return $this->t('Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.');
    }
    
    /**
     * Validates a set of package names.
     *
     * Package names are considered invalid if they look like Drupal project
     * names. The only exceptions to this are platform requirements, like `php`,
     * `composer`, or `ext-json`, which are legitimate to Composer.
     *
     * @param string[] $requirements
     *   A set of package names (with or without version constraints), as passed
     *   to ::require().
     *
     * @throws \InvalidArgumentException
     *   Thrown if any of the given package names fail basic validation.
     */
    protected static function validateRequirements(array $requirements) : void {
        $version_parser = new VersionParser();
        foreach ($requirements as $requirement) {
            $parts = explode(':', $requirement, 2);
            $name = $parts[0];
            if (!preg_match(self::COMPOSER_PLATFORM_PACKAGE_REGEX, $name) && !preg_match(self::COMPOSER_PACKAGE_REGEX, $name)) {
                throw new \InvalidArgumentException("Invalid package name '{$name}'.");
            }
            if (count($parts) > 1) {
                $version_parser->parseConstraints($parts[1]);
            }
        }
    }
    
    /**
     * Stores information about the stage when it is destroyed.
     *
     * @param bool $force
     *   Whether the stage was force destroyed.
     * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message
     *   A message about why the stage was destroyed or null.
     *
     * @throws \Drupal\Core\TempStore\TempStoreException
     */
    protected function storeDestroyInfo(bool $force, ?TranslatableMarkup $message) : void {
        if (!$message) {
            if ($this->tempStore
                ->get(self::TEMPSTORE_CHANGES_APPLIED) === TRUE) {
                $message = $this->t('This operation has already been applied.');
            }
            else {
                if ($force) {
                    $message = $this->t('This operation was canceled by another user.');
                }
                else {
                    $message = $this->t('This operation was already canceled.');
                }
            }
        }
        [
            $id,
        ] = $this->tempStore
            ->get(static::TEMPSTORE_LOCK_KEY);
        $this->tempStore
            ->set(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $id, $message);
    }

}

Members

Title Sort descending Modifiers Object type Summary Overrides
StageBase::$lock private property The lock info for the stage.
StageBase::$tempStore protected property The shared temp store.
StageBase::$type protected property The stage type. 3
StageBase::apply public function Applies staged changes to the active directory.
StageBase::checkOwnership final protected function Validates the ownership of stage directory.
StageBase::claim final public function Attempts to claim the stage.
StageBase::COMPOSER_PACKAGE_REGEX private constant The regular expression to check if a package name is a regular package.
StageBase::COMPOSER_PLATFORM_PACKAGE_REGEX private constant The regular expression to check if a package name is a platform package.
StageBase::computeDestroyMessage private function Returns the specific destroy message for the ID.
StageBase::create public function Copies the active code base into the stage directory.
StageBase::destroy public function Deletes the stage directory.
StageBase::dispatch protected function Dispatches an event and handles any errors that it collects.
StageBase::getFailureMarkerMessage protected function Returns the failure marker message.
StageBase::getMetadata public function Returns a specific piece of metadata associated with this stage.
StageBase::getPathsToExclude protected function Collects paths that Composer Stager should exclude.
StageBase::getStageDirectory public function Returns the path of the directory where changes should be staged.
StageBase::getStagingRoot private function Returns the directory where stage directories will be created.
StageBase::getType final public function Gets the stage type.
StageBase::isApplying final public function Checks if staged changes are being applied to the active directory.
StageBase::isAvailable final public function Determines if the stage directory can be created.
StageBase::markAsAvailable protected function Marks the stage as available.
StageBase::postApply public function Performs post-apply tasks.
StageBase::require public function Adds or updates packages in the stage directory.
StageBase::rethrowAsStageException private function Wraps an exception in a StageException and re-throws it.
StageBase::setMetadata public function Stores arbitrary metadata associated with this stage.
StageBase::setNotApplying private function Returns a closure that marks this stage as no longer being applied.
StageBase::stageDirectoryExists public function Determines if the stage directory exists.
StageBase::storeDestroyInfo protected function Stores information about the stage when it is destroyed.
StageBase::TEMPSTORE_APPLY_TIME_KEY private constant The tempstore key under which to store the time that ::apply() was called.
StageBase::TEMPSTORE_CHANGES_APPLIED private constant The tempstore key for whether staged operations have been applied.
StageBase::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX private constant The tempstore key for information about previously destroyed stages.
StageBase::TEMPSTORE_LOCK_KEY final protected constant The tempstore key under which to store the locking info for this stage.
StageBase::TEMPSTORE_METADATA_KEY final protected constant The tempstore key under which to store arbitrary metadata for this stage.
StageBase::TEMPSTORE_STAGING_ROOT_KEY private constant The tempstore key under which to store the path of stage root directory.
StageBase::validateRequirements protected static function Validates a set of package names.
StageBase::__construct public function 1
StringTranslationTrait::$stringTranslation protected property The string translation service. 3
StringTranslationTrait::formatPlural protected function Formats a string containing a count of items.
StringTranslationTrait::getNumberOfPlurals protected function Returns the number of plurals supported by a given language.
StringTranslationTrait::getStringTranslation protected function Gets the string translation service.
StringTranslationTrait::setStringTranslation public function Sets the string translation service to use. 2
StringTranslationTrait::t protected function Translates a string to the current language or to a given language. 1

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.