StageBase.php

Namespace

Drupal\package_manager

File

core/modules/package_manager/src/StageBase.php

View source
<?php

declare (strict_types=1);
namespace Drupal\package_manager;

use Composer\Semver\VersionParser;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\Random;
use Drupal\Core\Queue\QueueFactory;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TempStore\SharedTempStore;
use Drupal\Core\TempStore\SharedTempStoreFactory;
use Drupal\Core\Utility\Error;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\Event\PostApplyEvent;
use Drupal\package_manager\Event\PostCreateEvent;
use Drupal\package_manager\Event\PostRequireEvent;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\StageEvent;
use Drupal\package_manager\Exception\ApplyFailedException;
use Drupal\package_manager\Exception\StageEventException;
use Drupal\package_manager\Exception\StageException;
use Drupal\package_manager\Exception\StageOwnershipException;
use PhpTuf\ComposerStager\API\Core\BeginnerInterface;
use PhpTuf\ComposerStager\API\Core\CommitterInterface;
use PhpTuf\ComposerStager\API\Core\StagerInterface;
use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException;
use PhpTuf\ComposerStager\API\Exception\PreconditionException;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * 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.
 */
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);
    }

}

Classes

Title Deprecated Summary
StageBase Creates and manages a stage directory in which to install or update code.

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