From 8d81d07b2e85673f902692a6a9b5c42f87b0293d Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sun, 12 Jul 2026 13:23:06 -0400 Subject: [PATCH 1/3] Add zero-downtime index deployments --- README.md | 83 +++++- config/opensearch-deployments.php | 29 ++ src/Console/FreshCommand.php | 4 + src/Console/RefreshCommand.php | 10 +- src/Console/ResetCommand.php | 10 +- src/Deployer.php | 241 +++++++++++++++++ src/Deployments/Deployment.php | 219 +++++++++++++++ src/Deployments/DeploymentException.php | 7 + src/Deployments/DeploymentStatus.php | 10 + src/OpenSearchMigrationsServiceProvider.php | 10 + src/Repositories/DeploymentRepository.php | 125 +++++++++ .../Integration/Console/FreshCommandTest.php | 17 +- .../Console/RefreshCommandTest.php | 30 +++ .../Integration/Console/ResetCommandTest.php | 29 ++ .../Integration/Deployments/DeployerTest.php | 255 ++++++++++++++++++ .../Repositories/DeploymentRepositoryTest.php | 116 ++++++++ tests/Integration/TestCase.php | 1 + tests/Pest.php | 3 +- tests/Unit/Deployments/DeploymentTest.php | 58 ++++ 19 files changed, 1251 insertions(+), 6 deletions(-) create mode 100644 config/opensearch-deployments.php create mode 100644 src/Deployer.php create mode 100644 src/Deployments/Deployment.php create mode 100644 src/Deployments/DeploymentException.php create mode 100644 src/Deployments/DeploymentStatus.php create mode 100644 src/Repositories/DeploymentRepository.php create mode 100644 tests/Integration/Deployments/DeployerTest.php create mode 100644 tests/Integration/Repositories/DeploymentRepositoryTest.php create mode 100644 tests/Unit/Deployments/DeploymentTest.php diff --git a/README.md b/README.md index 3c26db1..be19ec2 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Publish the OpenSearch client configuration: php artisan vendor:publish --provider="DirectoryTree\OpenSearchClient\OpenSearchClientServiceProvider" ``` -Publish the migration configuration: +Publish the migration and deployment configuration: ```bash php artisan vendor:publish --provider="DirectoryTree\OpenSearchMigrations\OpenSearchMigrationsServiceProvider" @@ -33,7 +33,7 @@ php artisan vendor:publish --provider="DirectoryTree\OpenSearchMigrations\OpenSe ## Configuration -The migration configuration is published to `config/opensearch-migrations.php`: +Migration history and index naming are configured in `config/opensearch-migrations.php`: ```php 'table' => env('OPENSEARCH_MIGRATIONS_TABLE', 'opensearch_migrations'), @@ -47,6 +47,17 @@ The migration configuration is published to `config/opensearch-migrations.php`: 'alias_name_prefix' => env('OPENSEARCH_MIGRATIONS_ALIAS_NAME_PREFIX', env('SCOUT_PREFIX', '')), ``` +Deployment storage is configured separately in `config/opensearch-deployments.php`: + +```php +'table' => env('OPENSEARCH_DEPLOYMENTS_TABLE', 'opensearch_deployments'), + +'connection' => env( + 'OPENSEARCH_DEPLOYMENTS_CONNECTION', + env('OPENSEARCH_MIGRATIONS_CONNECTION') +), +``` + ## Creating Migrations Create a migration: @@ -124,6 +135,74 @@ Show migration status: php artisan opensearch:migrate:status ``` +## Zero-Downtime Index Deployments + +The deployer creates versioned physical indexes and tracks their lifecycle in the `opensearch_deployments` table. Applications remain responsible for backfilling and validating candidate documents. + +First, create a stable alias for the index in a migration: + +```php +Index::putAlias('posts', 'posts_search'); +``` + +Start a deployment with the latest mapping and settings: + +```php +use DirectoryTree\OpenSearchAdapter\Indices\Mapping; +use DirectoryTree\OpenSearchAdapter\Indices\Settings; +use DirectoryTree\OpenSearchMigrations\Deployer; + +$deployer = app(Deployer::class); + +$deployment = $deployer->start( + name: 'posts', + alias: 'posts_search', + configure: function (Mapping $mapping, Settings $settings) { + $mapping->text('title'); + $mapping->text('body'); + }, +); +``` + +The returned deployment exposes the physical candidate index for backfilling: + +```php +$deployment->candidateIndex; +``` + +While backfilling, send live writes and deletions to every deployment write index: + +```php +$deployment->writeIndexes(); +``` + +After application-specific validation succeeds, mark the candidate ready and atomically move the alias: + +```php +$deployer->markReady('posts'); +$deployer->cutover('posts'); +``` + +Cancel and delete a candidate that should not be promoted: + +```php +$deployer->cancel('posts'); +``` + +The previous index remains in the deployment's write indexes during the rollback window: + +```php +$deployer->rollback('posts'); +``` + +Once the new index is verified in production, delete the previous physical index and complete the deployment: + +```php +$deployer->retire('posts'); +``` + +`opensearch:migrate:fresh` deletes all deployment records because it drops all physical indexes. Migration reset and refresh commands refuse to run while managed deployments exist. + ## Credits This package builds on a lot of the foundation and prior work from [Ivan Babenko](https://github.com/babenkoivan) and his Elasticsearch Laravel ecosystem packages. diff --git a/config/opensearch-deployments.php b/config/opensearch-deployments.php new file mode 100644 index 0000000..fc8234c --- /dev/null +++ b/config/opensearch-deployments.php @@ -0,0 +1,29 @@ + env('OPENSEARCH_DEPLOYMENTS_TABLE', 'opensearch_deployments'), + + /* + |-------------------------------------------------------------------------- + | Database Connection + |-------------------------------------------------------------------------- + | + | This connection stores OpenSearch deployment state. When null, the + | application's default database connection will be used. + | + */ + + 'connection' => env( + 'OPENSEARCH_DEPLOYMENTS_CONNECTION', + env('OPENSEARCH_MIGRATIONS_CONNECTION') + ), +]; diff --git a/src/Console/FreshCommand.php b/src/Console/FreshCommand.php index 0847426..7567942 100644 --- a/src/Console/FreshCommand.php +++ b/src/Console/FreshCommand.php @@ -4,6 +4,7 @@ use DirectoryTree\OpenSearchMigrations\IndexManagerInterface; use DirectoryTree\OpenSearchMigrations\Migrator; +use DirectoryTree\OpenSearchMigrations\Repositories\DeploymentRepository; use DirectoryTree\OpenSearchMigrations\Repositories\MigrationRepository; use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; @@ -33,6 +34,7 @@ public function handle( Migrator $migrator, IndexManagerInterface $index, MigrationRepository $migrations, + DeploymentRepository $deployments, ): int { $migrator->setOutput($this->output); @@ -41,10 +43,12 @@ public function handle( } $migrator->prepare(); + $deployments->prepare(); $index->drop('*'); $migrations->deleteAll(); + $deployments->deleteAll(); $migrator->migrateAll(); diff --git a/src/Console/RefreshCommand.php b/src/Console/RefreshCommand.php index d637e95..ef9f205 100644 --- a/src/Console/RefreshCommand.php +++ b/src/Console/RefreshCommand.php @@ -3,6 +3,7 @@ namespace DirectoryTree\OpenSearchMigrations\Console; use DirectoryTree\OpenSearchMigrations\Migrator; +use DirectoryTree\OpenSearchMigrations\Repositories\DeploymentRepository; use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; @@ -31,7 +32,7 @@ class RefreshCommand extends Command /** * Execute the console command. */ - public function handle(Migrator $migrator): int + public function handle(Migrator $migrator, DeploymentRepository $deployments): int { $migrator->setOutput($this->output); @@ -40,6 +41,13 @@ public function handle(Migrator $migrator): int } $migrator->prepare(); + $deployments->prepare(); + + if ($deployments->exists()) { + $this->components->error('Managed indexes cannot be refreshed. Use opensearch:migrate:fresh to discard them.'); + + return static::FAILURE; + } $migrator->rollbackAll(); $migrator->migrateAll(); diff --git a/src/Console/ResetCommand.php b/src/Console/ResetCommand.php index 52ddb03..4457f32 100644 --- a/src/Console/ResetCommand.php +++ b/src/Console/ResetCommand.php @@ -3,6 +3,7 @@ namespace DirectoryTree\OpenSearchMigrations\Console; use DirectoryTree\OpenSearchMigrations\Migrator; +use DirectoryTree\OpenSearchMigrations\Repositories\DeploymentRepository; use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; @@ -31,7 +32,7 @@ class ResetCommand extends Command /** * Execute the console command. */ - public function handle(Migrator $migrator): int + public function handle(Migrator $migrator, DeploymentRepository $deployments): int { $migrator->setOutput($this->output); @@ -40,6 +41,13 @@ public function handle(Migrator $migrator): int } $migrator->prepare(); + $deployments->prepare(); + + if ($deployments->exists()) { + $this->components->error('Managed indexes cannot be reset. Use opensearch:migrate:fresh to discard them.'); + + return static::FAILURE; + } $migrator->rollbackAll(); diff --git a/src/Deployer.php b/src/Deployer.php new file mode 100644 index 0000000..09a4050 --- /dev/null +++ b/src/Deployer.php @@ -0,0 +1,241 @@ +deployments->prepare(); + + $existing = $this->deployments->find($name); + + if ($existing?->candidateIndex || $existing?->previousIndex) { + throw new DeploymentException('The current deployment must be completed or retired before starting another.'); + } + + $prefixedAlias = MigrationPrefix::alias($alias); + $activeIndex = $this->resolveAliasIndex($prefixedAlias); + $candidate = sprintf('%s_v%s', $name, $version ?? Date::now()->format('YmdHisv')); + + $this->indexes->create($candidate, $configure); + + return $this->deployments->save( + Deployment::backfilling( + name: $name, + alias: $prefixedAlias, + activeIndex: $activeIndex, + candidateIndex: MigrationPrefix::index($candidate), + now: Date::now(), + ) + ); + } + + /** + * Mark a candidate index as ready for cutover. + */ + public function markReady(string $name): Deployment + { + $deployment = $this->deployments->findOrFail($name); + + if (! $deployment->candidateIndex) { + throw new DeploymentException('The deployment does not have a candidate index to mark as ready.'); + } + + return $this->deployments->save( + $deployment->markReady(Date::now()) + ); + } + + /** + * Atomically switch the live alias to a ready candidate index. + */ + public function cutover(string $name): Deployment + { + $deployment = $this->deployments->findOrFail($name); + + if ($deployment->status !== DeploymentStatus::Ready || ! $deployment->candidateIndex) { + throw new DeploymentException('The candidate index must be ready before cutover.'); + } + + $deployment = $this->deployments->save( + $deployment->stageCutover() + ); + + $this->moveAlias( + $deployment->alias, + $deployment->activeIndex, + $deployment->candidateIndex + ); + + return $this->deployments->save( + $deployment->completeCutover(Date::now()) + ); + } + + /** + * Atomically restore the previous physical index. + */ + public function rollback(string $name): Deployment + { + $deployment = $this->deployments->findOrFail($name); + + if (! $deployment->previousIndex) { + throw new DeploymentException('The deployment does not have a previous index to restore.'); + } + + $deployment = $this->deployments->save( + $deployment->stageRollback() + ); + + $this->moveAlias( + $deployment->alias, + $deployment->activeIndex, + $deployment->previousIndex + ); + + return $this->deployments->save( + $deployment->completeRollback(Date::now()) + ); + } + + /** + * Delete a candidate index and return to the active deployment. + */ + public function cancel(string $name): Deployment + { + $deployment = $this->deployments->findOrFail($name); + + if (! $deployment->candidateIndex) { + throw new DeploymentException('The deployment does not have a candidate index to cancel.'); + } + + if ($this->resolveAliasIndex($deployment->alias) !== $deployment->activeIndex) { + throw new DeploymentException('The candidate index is already active and cannot be cancelled.'); + } + + $candidate = $deployment->candidateIndex; + + $this->deployments->save( + $deployment->cancelCandidate() + ); + + try { + $this->adapterIndexes->delete($candidate); + } catch (Throwable $exception) { + $this->deployments->save($deployment); + + throw $exception; + } + + return $this->deployments->findOrFail($name); + } + + /** + * Delete the previous physical index and complete a deployment. + */ + public function retire(string $name): Deployment + { + $deployment = $this->deployments->findOrFail($name); + + if (! $deployment->previousIndex) { + throw new DeploymentException('The deployment does not have a previous index to retire.'); + } + + $previous = $deployment->previousIndex; + + $this->deployments->save( + $deployment->retirePrevious() + ); + + try { + $this->adapterIndexes->delete($previous); + } catch (Throwable $exception) { + $this->deployments->save($deployment); + + throw $exception; + } + + return $this->deployments->findOrFail($name); + } + + /** + * Get a deployment by its logical index name. + */ + public function find(string $name): ?Deployment + { + $this->deployments->prepare(); + + return $this->deployments->find($name); + } + + /** + * Resolve the single physical index behind an alias. + */ + protected function resolveAliasIndex(string $alias): string + { + $indexes = array_keys( + $this->openSearch->default() + ->indices() + ->getAlias(['name' => $alias]) + ); + + if (count($indexes) !== 1) { + throw new DeploymentException("Alias [{$alias}] must point to exactly one index."); + } + + return $indexes[0]; + } + + /** + * Atomically move an alias between physical indexes. + */ + protected function moveAlias(string $alias, string $from, string $to): void + { + $current = $this->resolveAliasIndex($alias); + + if ($current === $to) { + return; + } + + if ($current !== $from) { + throw new DeploymentException("Alias [{$alias}] points to unexpected index [{$current}]."); + } + + $this->openSearch->default()->indices()->updateAliases([ + 'body' => [ + 'actions' => [ + ['remove' => ['index' => $from, 'alias' => $alias]], + ['add' => ['index' => $to, 'alias' => $alias, 'is_write_index' => true]], + ], + ], + ]); + } +} diff --git a/src/Deployments/Deployment.php b/src/Deployments/Deployment.php new file mode 100644 index 0000000..11eedae --- /dev/null +++ b/src/Deployments/Deployment.php @@ -0,0 +1,219 @@ +id, + name: $record->name, + alias: $record->alias, + activeIndex: $record->active_index, + candidateIndex: $record->candidate_index, + previousIndex: $record->previous_index, + status: DeploymentStatus::from($record->status), + readyAt: isset($record->ready_at) ? CarbonImmutable::parse($record->ready_at) : null, + cutoverAt: isset($record->cutover_at) ? CarbonImmutable::parse($record->cutover_at) : null, + createdAt: CarbonImmutable::parse($record->created_at), + updatedAt: CarbonImmutable::parse($record->updated_at), + ); + } + + /** + * Create a new deployment value object. + */ + protected function __construct( + public ?int $id, + public string $name, + public string $alias, + public string $activeIndex, + public ?string $candidateIndex, + public ?string $previousIndex, + public DeploymentStatus $status, + public ?CarbonImmutable $readyAt, + public ?CarbonImmutable $cutoverAt, + public CarbonImmutable $createdAt, + public CarbonImmutable $updatedAt, + ) {} + + /** + * Mark the candidate index as ready for cutover. + */ + public function markReady(CarbonInterface $now): static + { + return $this->transition( + activeIndex: $this->activeIndex, + candidateIndex: $this->candidateIndex, + previousIndex: $this->previousIndex, + status: DeploymentStatus::Ready, + readyAt: CarbonImmutable::instance($now), + cutoverAt: $this->cutoverAt, + ); + } + + /** + * Preserve the active index while an alias cutover is in progress. + */ + public function stageCutover(): static + { + return $this->transition( + activeIndex: $this->activeIndex, + candidateIndex: $this->candidateIndex, + previousIndex: $this->activeIndex, + status: $this->status, + readyAt: $this->readyAt, + cutoverAt: $this->cutoverAt, + ); + } + + /** + * Promote the candidate index after its alias cutover. + */ + public function completeCutover(CarbonInterface $now): static + { + return $this->transition( + activeIndex: $this->candidateIndex, + candidateIndex: null, + previousIndex: $this->previousIndex, + status: DeploymentStatus::Active, + readyAt: $this->readyAt, + cutoverAt: CarbonImmutable::instance($now), + ); + } + + /** + * Preserve the active index while an alias rollback is in progress. + */ + public function stageRollback(): static + { + return $this->transition( + activeIndex: $this->activeIndex, + candidateIndex: $this->activeIndex, + previousIndex: $this->previousIndex, + status: $this->status, + readyAt: $this->readyAt, + cutoverAt: $this->cutoverAt, + ); + } + + /** + * Restore the previous index after its alias rollback. + */ + public function completeRollback(CarbonInterface $now): static + { + return $this->transition( + activeIndex: $this->previousIndex, + candidateIndex: null, + previousIndex: $this->activeIndex, + status: DeploymentStatus::Active, + readyAt: $this->readyAt, + cutoverAt: CarbonImmutable::instance($now), + ); + } + + /** + * Return to the active index without a candidate. + */ + public function cancelCandidate(): static + { + return $this->transition( + activeIndex: $this->activeIndex, + candidateIndex: null, + previousIndex: null, + status: DeploymentStatus::Active, + readyAt: null, + cutoverAt: $this->cutoverAt, + ); + } + + /** + * Complete the rollback window without a previous index. + */ + public function retirePrevious(): static + { + return $this->transition( + activeIndex: $this->activeIndex, + candidateIndex: $this->candidateIndex, + previousIndex: null, + status: $this->status, + readyAt: $this->readyAt, + cutoverAt: $this->cutoverAt, + ); + } + + /** + * Get every index that should receive writes during this deployment. + * + * @return string[] + */ + public function writeIndexes(): array + { + return array_values(array_unique(array_filter([ + $this->alias, + $this->candidateIndex, + $this->previousIndex, + ]))); + } + + /** + * Create a new deployment containing the given lifecycle state. + */ + protected function transition( + string $activeIndex, + ?string $candidateIndex, + ?string $previousIndex, + DeploymentStatus $status, + ?CarbonImmutable $readyAt, + ?CarbonImmutable $cutoverAt, + ): static { + return new static( + id: $this->id, + name: $this->name, + alias: $this->alias, + activeIndex: $activeIndex, + candidateIndex: $candidateIndex, + previousIndex: $previousIndex, + status: $status, + readyAt: $readyAt, + cutoverAt: $cutoverAt, + createdAt: $this->createdAt, + updatedAt: $this->updatedAt, + ); + } +} diff --git a/src/Deployments/DeploymentException.php b/src/Deployments/DeploymentException.php new file mode 100644 index 0000000..636f6c2 --- /dev/null +++ b/src/Deployments/DeploymentException.php @@ -0,0 +1,7 @@ +mergeConfigFrom(__DIR__.'/../config/opensearch-migrations.php', 'opensearch-migrations'); + $this->mergeConfigFrom(__DIR__.'/../config/opensearch-deployments.php', 'opensearch-deployments'); $this->app->bind(IndexManagerInterface::class, IndexManagerAdapter::class); @@ -62,6 +64,13 @@ public function register(): void ); }); + $this->app->bind(DeploymentRepository::class, function (Application $app) { + return new DeploymentRepository( + $app['config']->get('opensearch-deployments.table'), + $app['config']->get('opensearch-deployments.connection') + ); + }); + $this->app->singleton(IndexManager::class, function (Application $app) { return new IndexManager($app->make(OpenSearchManager::class)->default()); }); @@ -76,6 +85,7 @@ public function boot(): void { $this->publishes([ __DIR__.'/../config/opensearch-migrations.php' => config_path('opensearch-migrations.php'), + __DIR__.'/../config/opensearch-deployments.php' => config_path('opensearch-deployments.php'), ]); $this->commands($this->commands); diff --git a/src/Repositories/DeploymentRepository.php b/src/Repositories/DeploymentRepository.php new file mode 100644 index 0000000..b8113f7 --- /dev/null +++ b/src/Repositories/DeploymentRepository.php @@ -0,0 +1,125 @@ +table()->updateOrInsert( + ['name' => $deployment->name], + [ + 'alias' => $deployment->alias, + 'active_index' => $deployment->activeIndex, + 'candidate_index' => $deployment->candidateIndex, + 'previous_index' => $deployment->previousIndex, + 'status' => $deployment->status->value, + 'ready_at' => $deployment->readyAt, + 'cutover_at' => $deployment->cutoverAt, + 'created_at' => $deployment->createdAt, + 'updated_at' => Date::now(), + ], + ); + + return $this->findOrFail($deployment->name); + } + + /** + * Find a deployment by its logical name. + */ + public function find(string $name): ?Deployment + { + $record = $this->table()->where('name', $name)->first(); + + return $record ? Deployment::fromRecord($record) : null; + } + + /** + * Find a deployment or throw an exception. + */ + public function findOrFail(string $name): Deployment + { + return $this->find($name) + ?? throw new DeploymentException("OpenSearch deployment [{$name}] does not exist."); + } + + /** + * Delete a deployment record. + */ + public function delete(string $name): bool + { + return (bool) $this->table()->where('name', $name)->delete(); + } + + /** + * Determine whether any managed deployments exist. + */ + public function exists(): bool + { + return $this->table()->exists(); + } + + /** + * Delete all deployment records. + */ + public function deleteAll(): void + { + $this->table()->delete(); + } + + /** + * Create the deployment repository table. + */ + public function create(): void + { + Schema::connection($this->connection)->create($this->table, function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->unique(); + $table->string('alias')->unique(); + $table->string('active_index'); + $table->string('candidate_index')->nullable(); + $table->string('previous_index')->nullable(); + $table->string('status'); + $table->timestamp('ready_at')->nullable(); + $table->timestamp('cutover_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Prepare the deployment repository table. + */ + public function prepare(): void + { + if (! Schema::connection($this->connection)->hasTable($this->table)) { + $this->create(); + } + } + + /** + * Get a query builder for the deployment table. + */ + protected function table(): Builder + { + return DB::connection($this->connection)->table($this->table); + } +} diff --git a/tests/Integration/Console/FreshCommandTest.php b/tests/Integration/Console/FreshCommandTest.php index 3befac8..b287e48 100644 --- a/tests/Integration/Console/FreshCommandTest.php +++ b/tests/Integration/Console/FreshCommandTest.php @@ -1,16 +1,30 @@ prepare(); + $deployments->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: now(), + )->cancelCandidate()); app()->instance(Migrator::class, $migrator); @@ -26,5 +40,6 @@ $repository->shouldReceive('deleteAll')->once(); $migrator->shouldReceive('migrateAll')->once(); - expect($command->run(new ArrayInput(['--force' => true]), new NullOutput))->toBe(0); + expect($command->run(new ArrayInput(['--force' => true]), new NullOutput))->toBe(0) + ->and($deployments->exists())->toBeFalse(); }); diff --git a/tests/Integration/Console/RefreshCommandTest.php b/tests/Integration/Console/RefreshCommandTest.php index 79b25c6..eae5c6e 100644 --- a/tests/Integration/Console/RefreshCommandTest.php +++ b/tests/Integration/Console/RefreshCommandTest.php @@ -1,10 +1,15 @@ instance(Migrator::class, $migrator); @@ -20,3 +25,28 @@ expect($command->run(new ArrayInput(['--force' => true]), new NullOutput))->toBe(0); }); + +it('refuses to refresh while managed deployments exist', function (): void { + $deployments = app(DeploymentRepository::class); + $deployments->prepare(); + $deployments->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: now(), + )->cancelCandidate()); + + $migrator = Mockery::mock(Migrator::class); + $migrator->shouldReceive('setOutput')->once()->andReturnSelf(); + $migrator->shouldReceive('prepare')->once()->andReturnSelf(); + $migrator->shouldNotReceive('rollbackAll'); + $migrator->shouldNotReceive('migrateAll'); + + app()->instance(Migrator::class, $migrator); + + $command = new RefreshCommand; + $command->setLaravel(app()); + + expect($command->run(new ArrayInput(['--force' => true]), new NullOutput))->toBe(1); +}); diff --git a/tests/Integration/Console/ResetCommandTest.php b/tests/Integration/Console/ResetCommandTest.php index c7d607d..3c9c355 100644 --- a/tests/Integration/Console/ResetCommandTest.php +++ b/tests/Integration/Console/ResetCommandTest.php @@ -1,10 +1,15 @@ instance(Migrator::class, $migrator); @@ -19,3 +24,27 @@ expect($command->run(new ArrayInput(['--force' => true]), new NullOutput))->toBe(0); }); + +it('refuses to reset while managed deployments exist', function (): void { + $deployments = app(DeploymentRepository::class); + $deployments->prepare(); + $deployments->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: now(), + )->cancelCandidate()); + + $migrator = Mockery::mock(Migrator::class); + $migrator->shouldReceive('setOutput')->once()->andReturnSelf(); + $migrator->shouldReceive('prepare')->once()->andReturnSelf(); + $migrator->shouldNotReceive('rollbackAll'); + + app()->instance(Migrator::class, $migrator); + + $command = new ResetCommand; + $command->setLaravel(app()); + + expect($command->run(new ArrayInput(['--force' => true]), new NullOutput))->toBe(1); +}); diff --git a/tests/Integration/Deployments/DeployerTest.php b/tests/Integration/Deployments/DeployerTest.php new file mode 100644 index 0000000..197d4fe --- /dev/null +++ b/tests/Integration/Deployments/DeployerTest.php @@ -0,0 +1,255 @@ +set('opensearch-migrations.index_name_prefix', 'test_'); + config()->set('opensearch-migrations.alias_name_prefix', 'test_'); + + $repository = app(DeploymentRepository::class); + $adapterIndexes = new FakeIndexManager; + $indexes = new IndexManagerAdapter($adapterIndexes); + + $indices = mock(IndicesNamespace::class); + $indices->shouldReceive('getAlias')->once()->with([ + 'name' => 'test_posts_search', + ])->andReturn([ + 'test_posts_blue' => ['aliases' => ['test_posts_search' => []]], + ]); + + $client = mock(Client::class); + $client->shouldReceive('indices')->once()->andReturn($indices); + + $openSearch = mock(OpenSearchManager::class); + $openSearch->shouldReceive('default')->once()->andReturn($client); + + $manager = new Deployer($repository, $indexes, $adapterIndexes, $openSearch); + + $deployment = $manager->start('posts', 'posts_search', function (Mapping $mapping, Settings $settings): void { + $mapping->text('title'); + $settings->index(['number_of_replicas' => 0]); + }); + + $adapterIndexes->assertCreated(new IndexBlueprint( + 'test_posts_v20260712123456789', + (new Mapping)->text('title'), + (new Settings)->index(['number_of_replicas' => 0]), + )); + + expect($deployment->name)->toBe('posts') + ->and($deployment->alias)->toBe('test_posts_search') + ->and($deployment->activeIndex)->toBe('test_posts_blue') + ->and($deployment->candidateIndex)->toBe('test_posts_v20260712123456789') + ->and($deployment->status)->toBe(DeploymentStatus::Backfilling); +}); + +it('marks a candidate ready and cuts over its alias atomically', function (): void { + $repository = app(DeploymentRepository::class); + $repository->prepare(); + $repository->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )); + + $adapterIndexes = new FakeIndexManager; + $indexes = new IndexManagerAdapter($adapterIndexes); + + $indices = mock(IndicesNamespace::class); + $indices->shouldReceive('getAlias')->once()->with([ + 'name' => 'posts_search', + ])->andReturn([ + 'posts_blue' => ['aliases' => ['posts_search' => []]], + ]); + $indices->shouldReceive('updateAliases')->once()->with([ + 'body' => [ + 'actions' => [ + ['remove' => ['index' => 'posts_blue', 'alias' => 'posts_search']], + ['add' => ['index' => 'posts_green', 'alias' => 'posts_search', 'is_write_index' => true]], + ], + ], + ]); + + $client = mock(Client::class); + $client->shouldReceive('indices')->twice()->andReturn($indices); + + $openSearch = mock(OpenSearchManager::class); + $openSearch->shouldReceive('default')->twice()->andReturn($client); + + $manager = new Deployer($repository, $indexes, $adapterIndexes, $openSearch); + + expect($manager->markReady('posts')->status)->toBe(DeploymentStatus::Ready); + + $deployment = $manager->cutover('posts'); + + expect($deployment->activeIndex)->toBe('posts_green') + ->and($deployment->candidateIndex)->toBeNull() + ->and($deployment->previousIndex)->toBe('posts_blue') + ->and($deployment->status)->toBe(DeploymentStatus::Active); +}); + +it('finishes a cutover when the alias was already moved', function (): void { + $repository = app(DeploymentRepository::class); + $repository->prepare(); + $repository->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )->markReady(Date::now())->stageCutover()); + + $adapterIndexes = new FakeIndexManager; + $indexes = new IndexManagerAdapter($adapterIndexes); + + $indices = mock(IndicesNamespace::class); + $indices->shouldReceive('getAlias')->once()->andReturn([ + 'posts_green' => ['aliases' => ['posts_search' => []]], + ]); + $indices->shouldNotReceive('updateAliases'); + + $client = mock(Client::class); + $client->shouldReceive('indices')->once()->andReturn($indices); + + $openSearch = mock(OpenSearchManager::class); + $openSearch->shouldReceive('default')->once()->andReturn($client); + + $deployment = (new Deployer($repository, $indexes, $adapterIndexes, $openSearch))->cutover('posts'); + + expect($deployment->activeIndex)->toBe('posts_green') + ->and($deployment->candidateIndex)->toBeNull() + ->and($deployment->previousIndex)->toBe('posts_blue'); +}); + +it('rolls a deployment back atomically', function (): void { + $repository = app(DeploymentRepository::class); + $repository->prepare(); + $repository->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )->markReady(Date::now())->stageCutover()->completeCutover(Date::now())); + + $adapterIndexes = new FakeIndexManager; + $indexes = new IndexManagerAdapter($adapterIndexes); + + $indices = mock(IndicesNamespace::class); + $indices->shouldReceive('getAlias')->once()->andReturn([ + 'posts_green' => ['aliases' => ['posts_search' => []]], + ]); + $indices->shouldReceive('updateAliases')->once(); + + $client = mock(Client::class); + $client->shouldReceive('indices')->twice()->andReturn($indices); + + $openSearch = mock(OpenSearchManager::class); + $openSearch->shouldReceive('default')->twice()->andReturn($client); + + $deployment = (new Deployer($repository, $indexes, $adapterIndexes, $openSearch))->rollback('posts'); + + expect($deployment->activeIndex)->toBe('posts_blue') + ->and($deployment->candidateIndex)->toBeNull() + ->and($deployment->previousIndex)->toBe('posts_green'); +}); + +it('cancels and deletes a candidate index', function (): void { + $repository = app(DeploymentRepository::class); + $repository->prepare(); + $repository->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )); + + $adapterIndexes = new FakeIndexManager; + $indexes = new IndexManagerAdapter($adapterIndexes); + + $indices = mock(IndicesNamespace::class); + $indices->shouldReceive('getAlias')->once()->andReturn([ + 'posts_blue' => ['aliases' => ['posts_search' => []]], + ]); + + $client = mock(Client::class); + $client->shouldReceive('indices')->once()->andReturn($indices); + + $openSearch = mock(OpenSearchManager::class); + $openSearch->shouldReceive('default')->once()->andReturn($client); + + $deployment = (new Deployer($repository, $indexes, $adapterIndexes, $openSearch))->cancel('posts'); + + $adapterIndexes->assertDeleted('posts_green'); + + expect($deployment->activeIndex)->toBe('posts_blue') + ->and($deployment->candidateIndex)->toBeNull() + ->and($deployment->status)->toBe(DeploymentStatus::Active); +}); + +it('retires the previous physical index', function (): void { + $repository = app(DeploymentRepository::class); + $repository->prepare(); + $repository->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )->markReady(Date::now())->stageCutover()->completeCutover(Date::now())); + + $adapterIndexes = new FakeIndexManager; + $indexes = new IndexManagerAdapter($adapterIndexes); + $openSearch = mock(OpenSearchManager::class); + + $deployment = (new Deployer($repository, $indexes, $adapterIndexes, $openSearch))->retire('posts'); + + $adapterIndexes->assertDeleted('posts_blue'); + + expect($deployment->activeIndex)->toBe('posts_green') + ->and($deployment->previousIndex)->toBeNull(); +}); + +it('restores deployment state when retirement fails', function (): void { + $repository = app(DeploymentRepository::class); + $repository->prepare(); + $repository->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )->markReady(Date::now())->stageCutover()->completeCutover(Date::now())); + + $adapterIndexes = mock(AdapterIndexManagerInterface::class); + $adapterIndexes->shouldReceive('delete')->once()->andThrow(new DeploymentException('Delete failed.')); + + $indexes = new IndexManagerAdapter($adapterIndexes); + $openSearch = mock(OpenSearchManager::class); + + expect(fn () => (new Deployer($repository, $indexes, $adapterIndexes, $openSearch))->retire('posts')) + ->toThrow(DeploymentException::class, 'Delete failed.'); + + expect($repository->findOrFail('posts')->previousIndex)->toBe('posts_blue'); +}); diff --git a/tests/Integration/Repositories/DeploymentRepositoryTest.php b/tests/Integration/Repositories/DeploymentRepositoryTest.php new file mode 100644 index 0000000..5a95134 --- /dev/null +++ b/tests/Integration/Repositories/DeploymentRepositoryTest.php @@ -0,0 +1,116 @@ +toBeFalse(); + + app(DeploymentRepository::class)->prepare(); + + expect(Schema::hasTable($table))->toBeTrue() + ->and(Schema::hasColumns($table, [ + 'id', + 'name', + 'alias', + 'active_index', + 'candidate_index', + 'previous_index', + 'status', + 'ready_at', + 'cutover_at', + 'created_at', + 'updated_at', + ]))->toBeTrue(); +}); + +it('stores and retrieves deployment state', function (): void { + Date::setTestNow('2026-07-12 12:00:00'); + + $repository = app(DeploymentRepository::class); + $repository->prepare(); + + $deployment = $repository->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )); + + expect($deployment->name)->toBe('posts') + ->and($deployment->alias)->toBe('posts_search') + ->and($deployment->activeIndex)->toBe('posts_blue') + ->and($deployment->candidateIndex)->toBe('posts_green') + ->and($deployment->previousIndex)->toBeNull() + ->and($deployment->status)->toBe(DeploymentStatus::Backfilling) + ->and($deployment->createdAt->toDateTimeString())->toBe('2026-07-12 12:00:00'); +}); + +it('updates deployment state without replacing its creation time', function (): void { + Date::setTestNow('2026-07-12 12:00:00'); + + $repository = app(DeploymentRepository::class); + $repository->prepare(); + + $repository->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )); + + Date::setTestNow('2026-07-12 13:00:00'); + + $deployment = $repository->save( + $repository->findOrFail('posts')->markReady(Date::now()) + ); + + expect($deployment->status)->toBe(DeploymentStatus::Ready) + ->and($deployment->createdAt->toDateTimeString())->toBe('2026-07-12 12:00:00') + ->and($deployment->updatedAt->toDateTimeString())->toBe('2026-07-12 13:00:00'); +}); + +it('uses the configured deployment connection', function (): void { + config()->set('database.connections.opensearch_deployments', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + config()->set('opensearch-deployments.connection', 'opensearch_deployments'); + config()->set('opensearch-deployments.table', 'custom_opensearch_deployments'); + + app(DeploymentRepository::class)->prepare(); + + expect(Schema::hasTable('custom_opensearch_deployments'))->toBeFalse() + ->and(Schema::connection('opensearch_deployments')->hasTable('custom_opensearch_deployments'))->toBeTrue(); +}); + +it('checks for and deletes all deployment records', function (): void { + $repository = app(DeploymentRepository::class); + $repository->prepare(); + + expect($repository->exists())->toBeFalse(); + + $repository->save(Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )->cancelCandidate()); + + expect($repository->exists())->toBeTrue(); + + $repository->deleteAll(); + + expect($repository->exists())->toBeFalse(); +}); diff --git a/tests/Integration/TestCase.php b/tests/Integration/TestCase.php index 469d355..5fbba9a 100644 --- a/tests/Integration/TestCase.php +++ b/tests/Integration/TestCase.php @@ -23,6 +23,7 @@ protected function getEnvironmentSetUp($app): void parent::getEnvironmentSetUp($app); $app['config']->set('opensearch-migrations.table', 'test_opensearch_migrations'); + $app['config']->set('opensearch-deployments.table', 'test_opensearch_deployments'); $app['config']->set('opensearch-migrations.storage_directory', realpath(__DIR__.'/../migrations')); $client = $this->createMock(Client::class); diff --git a/tests/Pest.php b/tests/Pest.php index 7e18c25..3619f61 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -8,6 +8,7 @@ 'Integration/Factories', 'Integration/Filesystem', 'Integration/Repositories', + 'Integration/Deployments', 'Integration/Support', 'Integration/MigratorTest.php', ); @@ -15,4 +16,4 @@ 'Unit/Adapters', 'Unit/Facades', ); -uses(UnitTestCase::class)->in('Unit/Filesystem'); +uses(UnitTestCase::class)->in('Unit/Deployments', 'Unit/Filesystem'); diff --git a/tests/Unit/Deployments/DeploymentTest.php b/tests/Unit/Deployments/DeploymentTest.php new file mode 100644 index 0000000..750d63e --- /dev/null +++ b/tests/Unit/Deployments/DeploymentTest.php @@ -0,0 +1,58 @@ +stageCutover(); + + expect($deployment->writeIndexes())->toBe([ + 'posts_search', + 'posts_green', + 'posts_blue', + ]); +}); + +it('expresses the deployment lifecycle through typed transitions', function (): void { + $startedAt = CarbonImmutable::parse('2026-07-12 12:00:00'); + $readyAt = CarbonImmutable::parse('2026-07-12 13:00:00'); + $cutoverAt = CarbonImmutable::parse('2026-07-12 14:00:00'); + + $deployment = Deployment::backfilling( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: $startedAt, + ); + + expect($deployment->id)->toBeNull() + ->and($deployment->status)->toBe(DeploymentStatus::Backfilling) + ->and($deployment->createdAt)->toEqual($startedAt); + + $deployment = $deployment->markReady($readyAt)->stageCutover(); + + expect($deployment->status)->toBe(DeploymentStatus::Ready) + ->and($deployment->readyAt)->toEqual($readyAt) + ->and($deployment->previousIndex)->toBe('posts_blue'); + + $deployment = $deployment->completeCutover($cutoverAt); + + expect($deployment->status)->toBe(DeploymentStatus::Active) + ->and($deployment->activeIndex)->toBe('posts_green') + ->and($deployment->candidateIndex)->toBeNull() + ->and($deployment->previousIndex)->toBe('posts_blue') + ->and($deployment->cutoverAt)->toEqual($cutoverAt); + + $deployment = $deployment->stageRollback()->completeRollback($cutoverAt); + + expect($deployment->activeIndex)->toBe('posts_blue') + ->and($deployment->previousIndex)->toBe('posts_green'); +}); From 254475adbeeb86e9a9b67146e0b376a01b94e0ec Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 13 Jul 2026 12:24:09 -0400 Subject: [PATCH 2/3] Separate provisioning from backfilling --- README.md | 11 ++-- src/Deployer.php | 26 +++++++-- src/Deployments/Deployment.php | 28 +++++++-- src/Deployments/DeploymentStatus.php | 1 + .../Integration/Console/FreshCommandTest.php | 2 +- .../Console/RefreshCommandTest.php | 2 +- .../Integration/Console/ResetCommandTest.php | 2 +- .../Integration/Deployments/DeployerTest.php | 57 ++++++++++++++----- .../Repositories/DeploymentRepositoryTest.php | 10 ++-- tests/Unit/Deployments/DeploymentTest.php | 18 ++++-- 10 files changed, 119 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index be19ec2..488bb90 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ First, create a stable alias for the index in a migration: Index::putAlias('posts', 'posts_search'); ``` -Start a deployment with the latest mapping and settings: +Provision a candidate with the latest mapping and settings: ```php use DirectoryTree\OpenSearchAdapter\Indices\Mapping; @@ -154,7 +154,7 @@ use DirectoryTree\OpenSearchMigrations\Deployer; $deployer = app(Deployer::class); -$deployment = $deployer->start( +$deployment = $deployer->provision( name: 'posts', alias: 'posts_search', configure: function (Mapping $mapping, Settings $settings) { @@ -164,15 +164,18 @@ $deployment = $deployer->start( ); ``` -The returned deployment exposes the physical candidate index for backfilling: +The returned deployment exposes the physical candidate index for inspection while writes continue targeting only the active alias: ```php $deployment->candidateIndex; +$deployment->writeIndexes(); // ['posts_search'] ``` -While backfilling, send live writes and deletions to every deployment write index: +Once the candidate has been inspected, begin backfilling before importing historical documents. This enables concurrent writes and deletions to the candidate: ```php +$deployment = $deployer->beginBackfill('posts'); + $deployment->writeIndexes(); ``` diff --git a/src/Deployer.php b/src/Deployer.php index 09a4050..40a04f5 100644 --- a/src/Deployer.php +++ b/src/Deployer.php @@ -25,9 +25,9 @@ public function __construct( ) {} /** - * Create a candidate index and begin a deployment. + * Provision a candidate index without enabling concurrent writes. */ - public function start( + public function provision( string $name, string $alias, ?callable $configure = null, @@ -48,7 +48,7 @@ public function start( $this->indexes->create($candidate, $configure); return $this->deployments->save( - Deployment::backfilling( + Deployment::provisioned( name: $name, alias: $prefixedAlias, activeIndex: $activeIndex, @@ -58,6 +58,22 @@ public function start( ); } + /** + * Begin backfilling a provisioned candidate index. + */ + public function beginBackfill(string $name): Deployment + { + $deployment = $this->deployments->findOrFail($name); + + if ($deployment->status !== DeploymentStatus::Provisioned || ! $deployment->candidateIndex) { + throw new DeploymentException('The candidate index must be provisioned before backfilling.'); + } + + return $this->deployments->save( + $deployment->beginBackfill() + ); + } + /** * Mark a candidate index as ready for cutover. */ @@ -65,8 +81,8 @@ public function markReady(string $name): Deployment { $deployment = $this->deployments->findOrFail($name); - if (! $deployment->candidateIndex) { - throw new DeploymentException('The deployment does not have a candidate index to mark as ready.'); + if ($deployment->status !== DeploymentStatus::Backfilling || ! $deployment->candidateIndex) { + throw new DeploymentException('The candidate index must be backfilling before it can be marked as ready.'); } return $this->deployments->save( diff --git a/src/Deployments/Deployment.php b/src/Deployments/Deployment.php index 11eedae..6ddd230 100644 --- a/src/Deployments/Deployment.php +++ b/src/Deployments/Deployment.php @@ -9,9 +9,9 @@ class Deployment { /** - * Create a deployment that is ready to be backfilled. + * Create a deployment with a provisioned candidate index. */ - public static function backfilling( + public static function provisioned( string $name, string $alias, string $activeIndex, @@ -27,7 +27,7 @@ public static function backfilling( activeIndex: $activeIndex, candidateIndex: $candidateIndex, previousIndex: null, - status: DeploymentStatus::Backfilling, + status: DeploymentStatus::Provisioned, readyAt: null, cutoverAt: null, createdAt: $now, @@ -72,6 +72,21 @@ protected function __construct( public CarbonImmutable $updatedAt, ) {} + /** + * Begin backfilling the candidate index. + */ + public function beginBackfill(): static + { + return $this->transition( + activeIndex: $this->activeIndex, + candidateIndex: $this->candidateIndex, + previousIndex: $this->previousIndex, + status: DeploymentStatus::Backfilling, + readyAt: $this->readyAt, + cutoverAt: $this->cutoverAt, + ); + } + /** * Mark the candidate index as ready for cutover. */ @@ -184,9 +199,14 @@ public function retirePrevious(): static */ public function writeIndexes(): array { + $candidate = in_array($this->status, [ + DeploymentStatus::Backfilling, + DeploymentStatus::Ready, + ], true) ? $this->candidateIndex : null; + return array_values(array_unique(array_filter([ $this->alias, - $this->candidateIndex, + $candidate, $this->previousIndex, ]))); } diff --git a/src/Deployments/DeploymentStatus.php b/src/Deployments/DeploymentStatus.php index f24c3cb..2036ab4 100644 --- a/src/Deployments/DeploymentStatus.php +++ b/src/Deployments/DeploymentStatus.php @@ -6,5 +6,6 @@ enum DeploymentStatus: string { case Active = 'active'; case Backfilling = 'backfilling'; + case Provisioned = 'provisioned'; case Ready = 'ready'; } diff --git a/tests/Integration/Console/FreshCommandTest.php b/tests/Integration/Console/FreshCommandTest.php index b287e48..2713bc8 100644 --- a/tests/Integration/Console/FreshCommandTest.php +++ b/tests/Integration/Console/FreshCommandTest.php @@ -18,7 +18,7 @@ $index = Mockery::mock(IndexManagerInterface::class); $deployments = app(DeploymentRepository::class); $deployments->prepare(); - $deployments->save(Deployment::backfilling( + $deployments->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', diff --git a/tests/Integration/Console/RefreshCommandTest.php b/tests/Integration/Console/RefreshCommandTest.php index eae5c6e..501cf9a 100644 --- a/tests/Integration/Console/RefreshCommandTest.php +++ b/tests/Integration/Console/RefreshCommandTest.php @@ -29,7 +29,7 @@ it('refuses to refresh while managed deployments exist', function (): void { $deployments = app(DeploymentRepository::class); $deployments->prepare(); - $deployments->save(Deployment::backfilling( + $deployments->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', diff --git a/tests/Integration/Console/ResetCommandTest.php b/tests/Integration/Console/ResetCommandTest.php index 3c9c355..7afa453 100644 --- a/tests/Integration/Console/ResetCommandTest.php +++ b/tests/Integration/Console/ResetCommandTest.php @@ -28,7 +28,7 @@ it('refuses to reset while managed deployments exist', function (): void { $deployments = app(DeploymentRepository::class); $deployments->prepare(); - $deployments->save(Deployment::backfilling( + $deployments->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', diff --git a/tests/Integration/Deployments/DeployerTest.php b/tests/Integration/Deployments/DeployerTest.php index 197d4fe..ae28d40 100644 --- a/tests/Integration/Deployments/DeployerTest.php +++ b/tests/Integration/Deployments/DeployerTest.php @@ -19,7 +19,7 @@ uses(RefreshDatabase::class); -it('creates a versioned candidate and records deployment state', function (): void { +it('provisions a versioned candidate before enabling concurrent writes', function (): void { Date::setTestNow('2026-07-12 12:34:56.789'); config()->set('opensearch-migrations.index_name_prefix', 'test_'); config()->set('opensearch-migrations.alias_name_prefix', 'test_'); @@ -43,7 +43,7 @@ $manager = new Deployer($repository, $indexes, $adapterIndexes, $openSearch); - $deployment = $manager->start('posts', 'posts_search', function (Mapping $mapping, Settings $settings): void { + $deployment = $manager->provision('posts', 'posts_search', function (Mapping $mapping, Settings $settings): void { $mapping->text('title'); $settings->index(['number_of_replicas' => 0]); }); @@ -58,13 +58,22 @@ ->and($deployment->alias)->toBe('test_posts_search') ->and($deployment->activeIndex)->toBe('test_posts_blue') ->and($deployment->candidateIndex)->toBe('test_posts_v20260712123456789') - ->and($deployment->status)->toBe(DeploymentStatus::Backfilling); + ->and($deployment->status)->toBe(DeploymentStatus::Provisioned) + ->and($deployment->writeIndexes())->toBe(['test_posts_search']); + + $deployment = $manager->beginBackfill('posts'); + + expect($deployment->status)->toBe(DeploymentStatus::Backfilling) + ->and($deployment->writeIndexes())->toBe([ + 'test_posts_search', + 'test_posts_v20260712123456789', + ]); }); it('marks a candidate ready and cuts over its alias atomically', function (): void { $repository = app(DeploymentRepository::class); $repository->prepare(); - $repository->save(Deployment::backfilling( + $repository->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', @@ -98,6 +107,7 @@ $manager = new Deployer($repository, $indexes, $adapterIndexes, $openSearch); + expect($manager->beginBackfill('posts')->status)->toBe(DeploymentStatus::Backfilling); expect($manager->markReady('posts')->status)->toBe(DeploymentStatus::Ready); $deployment = $manager->cutover('posts'); @@ -108,16 +118,37 @@ ->and($deployment->status)->toBe(DeploymentStatus::Active); }); +it('does not mark a provisioned candidate ready before backfilling begins', function (): void { + $repository = app(DeploymentRepository::class); + $repository->prepare(); + $repository->save(Deployment::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )); + + $adapterIndexes = new FakeIndexManager; + $indexes = new IndexManagerAdapter($adapterIndexes); + $openSearch = mock(OpenSearchManager::class); + + $deployer = new Deployer($repository, $indexes, $adapterIndexes, $openSearch); + + expect(fn () => $deployer->markReady('posts')) + ->toThrow(DeploymentException::class, 'The candidate index must be backfilling before it can be marked as ready.'); +}); + it('finishes a cutover when the alias was already moved', function (): void { $repository = app(DeploymentRepository::class); $repository->prepare(); - $repository->save(Deployment::backfilling( + $repository->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', candidateIndex: 'posts_green', now: Date::now(), - )->markReady(Date::now())->stageCutover()); + )->beginBackfill()->markReady(Date::now())->stageCutover()); $adapterIndexes = new FakeIndexManager; $indexes = new IndexManagerAdapter($adapterIndexes); @@ -144,13 +175,13 @@ it('rolls a deployment back atomically', function (): void { $repository = app(DeploymentRepository::class); $repository->prepare(); - $repository->save(Deployment::backfilling( + $repository->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', candidateIndex: 'posts_green', now: Date::now(), - )->markReady(Date::now())->stageCutover()->completeCutover(Date::now())); + )->beginBackfill()->markReady(Date::now())->stageCutover()->completeCutover(Date::now())); $adapterIndexes = new FakeIndexManager; $indexes = new IndexManagerAdapter($adapterIndexes); @@ -177,7 +208,7 @@ it('cancels and deletes a candidate index', function (): void { $repository = app(DeploymentRepository::class); $repository->prepare(); - $repository->save(Deployment::backfilling( + $repository->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', @@ -211,13 +242,13 @@ it('retires the previous physical index', function (): void { $repository = app(DeploymentRepository::class); $repository->prepare(); - $repository->save(Deployment::backfilling( + $repository->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', candidateIndex: 'posts_green', now: Date::now(), - )->markReady(Date::now())->stageCutover()->completeCutover(Date::now())); + )->beginBackfill()->markReady(Date::now())->stageCutover()->completeCutover(Date::now())); $adapterIndexes = new FakeIndexManager; $indexes = new IndexManagerAdapter($adapterIndexes); @@ -234,13 +265,13 @@ it('restores deployment state when retirement fails', function (): void { $repository = app(DeploymentRepository::class); $repository->prepare(); - $repository->save(Deployment::backfilling( + $repository->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', candidateIndex: 'posts_green', now: Date::now(), - )->markReady(Date::now())->stageCutover()->completeCutover(Date::now())); + )->beginBackfill()->markReady(Date::now())->stageCutover()->completeCutover(Date::now())); $adapterIndexes = mock(AdapterIndexManagerInterface::class); $adapterIndexes->shouldReceive('delete')->once()->andThrow(new DeploymentException('Delete failed.')); diff --git a/tests/Integration/Repositories/DeploymentRepositoryTest.php b/tests/Integration/Repositories/DeploymentRepositoryTest.php index 5a95134..43c72f8 100644 --- a/tests/Integration/Repositories/DeploymentRepositoryTest.php +++ b/tests/Integration/Repositories/DeploymentRepositoryTest.php @@ -38,7 +38,7 @@ $repository = app(DeploymentRepository::class); $repository->prepare(); - $deployment = $repository->save(Deployment::backfilling( + $deployment = $repository->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', @@ -51,7 +51,7 @@ ->and($deployment->activeIndex)->toBe('posts_blue') ->and($deployment->candidateIndex)->toBe('posts_green') ->and($deployment->previousIndex)->toBeNull() - ->and($deployment->status)->toBe(DeploymentStatus::Backfilling) + ->and($deployment->status)->toBe(DeploymentStatus::Provisioned) ->and($deployment->createdAt->toDateTimeString())->toBe('2026-07-12 12:00:00'); }); @@ -61,7 +61,7 @@ $repository = app(DeploymentRepository::class); $repository->prepare(); - $repository->save(Deployment::backfilling( + $repository->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', @@ -72,7 +72,7 @@ Date::setTestNow('2026-07-12 13:00:00'); $deployment = $repository->save( - $repository->findOrFail('posts')->markReady(Date::now()) + $repository->findOrFail('posts')->beginBackfill()->markReady(Date::now()) ); expect($deployment->status)->toBe(DeploymentStatus::Ready) @@ -100,7 +100,7 @@ expect($repository->exists())->toBeFalse(); - $repository->save(Deployment::backfilling( + $repository->save(Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', diff --git a/tests/Unit/Deployments/DeploymentTest.php b/tests/Unit/Deployments/DeploymentTest.php index 750d63e..59d503b 100644 --- a/tests/Unit/Deployments/DeploymentTest.php +++ b/tests/Unit/Deployments/DeploymentTest.php @@ -5,13 +5,19 @@ use DirectoryTree\OpenSearchMigrations\Deployments\DeploymentStatus; it('returns unique deployment write indexes', function (): void { - $deployment = Deployment::backfilling( + $deployment = Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', candidateIndex: 'posts_green', now: CarbonImmutable::now(), - )->stageCutover(); + ); + + expect($deployment->writeIndexes())->toBe([ + 'posts_search', + ]); + + $deployment = $deployment->beginBackfill()->stageCutover(); expect($deployment->writeIndexes())->toBe([ 'posts_search', @@ -25,7 +31,7 @@ $readyAt = CarbonImmutable::parse('2026-07-12 13:00:00'); $cutoverAt = CarbonImmutable::parse('2026-07-12 14:00:00'); - $deployment = Deployment::backfilling( + $deployment = Deployment::provisioned( name: 'posts', alias: 'posts_search', activeIndex: 'posts_blue', @@ -34,9 +40,13 @@ ); expect($deployment->id)->toBeNull() - ->and($deployment->status)->toBe(DeploymentStatus::Backfilling) + ->and($deployment->status)->toBe(DeploymentStatus::Provisioned) ->and($deployment->createdAt)->toEqual($startedAt); + $deployment = $deployment->beginBackfill(); + + expect($deployment->status)->toBe(DeploymentStatus::Backfilling); + $deployment = $deployment->markReady($readyAt)->stageCutover(); expect($deployment->status)->toBe(DeploymentStatus::Ready) From 9c3eefd068a87f93f7189af2f7c360cfe7e8f836 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 13 Jul 2026 13:31:01 -0400 Subject: [PATCH 3/3] Add deployment lifecycle commands --- README.md | 32 ++- src/Console/DeploymentBackfillCommand.php | 47 +++++ src/Console/DeploymentCancelCommand.php | 47 +++++ src/Console/DeploymentCutoverCommand.php | 47 +++++ src/Console/DeploymentReadyCommand.php | 47 +++++ src/Console/DeploymentRetireCommand.php | 47 +++++ src/Console/DeploymentRollbackCommand.php | 47 +++++ src/Console/DeploymentStatusCommand.php | 54 +++++ src/Deployer.php | 2 +- src/OpenSearchMigrationsServiceProvider.php | 14 ++ src/Repositories/DeploymentRepository.php | 14 ++ .../Console/DeploymentCommandsTest.php | 190 ++++++++++++++++++ .../Integration/Deployments/DeployerTest.php | 4 +- 13 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 src/Console/DeploymentBackfillCommand.php create mode 100644 src/Console/DeploymentCancelCommand.php create mode 100644 src/Console/DeploymentCutoverCommand.php create mode 100644 src/Console/DeploymentReadyCommand.php create mode 100644 src/Console/DeploymentRetireCommand.php create mode 100644 src/Console/DeploymentRollbackCommand.php create mode 100644 src/Console/DeploymentStatusCommand.php create mode 100644 tests/Integration/Console/DeploymentCommandsTest.php diff --git a/README.md b/README.md index 488bb90..db319c7 100644 --- a/README.md +++ b/README.md @@ -174,11 +174,17 @@ $deployment->writeIndexes(); // ['posts_search'] Once the candidate has been inspected, begin backfilling before importing historical documents. This enables concurrent writes and deletions to the candidate: ```php -$deployment = $deployer->beginBackfill('posts'); +$deployment = $deployer->backfill('posts'); $deployment->writeIndexes(); ``` +The same transition is available from the command line: + +```bash +php artisan opensearch:deploy:backfill posts +``` + After application-specific validation succeeds, mark the candidate ready and atomically move the alias: ```php @@ -186,24 +192,48 @@ $deployer->markReady('posts'); $deployer->cutover('posts'); ``` +```bash +php artisan opensearch:deploy:ready posts +php artisan opensearch:deploy:cutover posts +``` + Cancel and delete a candidate that should not be promoted: ```php $deployer->cancel('posts'); ``` +```bash +php artisan opensearch:deploy:cancel posts +``` + The previous index remains in the deployment's write indexes during the rollback window: ```php $deployer->rollback('posts'); ``` +```bash +php artisan opensearch:deploy:rollback posts +``` + Once the new index is verified in production, delete the previous physical index and complete the deployment: ```php $deployer->retire('posts'); ``` +```bash +php artisan opensearch:deploy:retire posts +``` + +Inspect one or every deployment at any point in the lifecycle: + +```bash +php artisan opensearch:deploy:status +php artisan opensearch:deploy:status posts +``` + `opensearch:migrate:fresh` deletes all deployment records because it drops all physical indexes. Migration reset and refresh commands refuse to run while managed deployments exist. ## Credits diff --git a/src/Console/DeploymentBackfillCommand.php b/src/Console/DeploymentBackfillCommand.php new file mode 100644 index 0000000..831fae3 --- /dev/null +++ b/src/Console/DeploymentBackfillCommand.php @@ -0,0 +1,47 @@ +confirmToProceed()) { + return static::FAILURE; + } + + $deployer->backfill($this->argument('name')); + + $this->components->info("Deployment [{$this->argument('name')}] is ready to be backfilled."); + + return static::SUCCESS; + } +} diff --git a/src/Console/DeploymentCancelCommand.php b/src/Console/DeploymentCancelCommand.php new file mode 100644 index 0000000..1e7d95b --- /dev/null +++ b/src/Console/DeploymentCancelCommand.php @@ -0,0 +1,47 @@ +confirmToProceed()) { + return static::FAILURE; + } + + $deployer->cancel($this->argument('name')); + + $this->components->info("Deployment [{$this->argument('name')}] was cancelled."); + + return static::SUCCESS; + } +} diff --git a/src/Console/DeploymentCutoverCommand.php b/src/Console/DeploymentCutoverCommand.php new file mode 100644 index 0000000..1982b53 --- /dev/null +++ b/src/Console/DeploymentCutoverCommand.php @@ -0,0 +1,47 @@ +confirmToProceed()) { + return static::FAILURE; + } + + $deployer->cutover($this->argument('name')); + + $this->components->info("Deployment [{$this->argument('name')}] was cut over."); + + return static::SUCCESS; + } +} diff --git a/src/Console/DeploymentReadyCommand.php b/src/Console/DeploymentReadyCommand.php new file mode 100644 index 0000000..312ddfa --- /dev/null +++ b/src/Console/DeploymentReadyCommand.php @@ -0,0 +1,47 @@ +confirmToProceed()) { + return static::FAILURE; + } + + $deployer->markReady($this->argument('name')); + + $this->components->info("Deployment [{$this->argument('name')}] is ready for cutover."); + + return static::SUCCESS; + } +} diff --git a/src/Console/DeploymentRetireCommand.php b/src/Console/DeploymentRetireCommand.php new file mode 100644 index 0000000..3fec4a6 --- /dev/null +++ b/src/Console/DeploymentRetireCommand.php @@ -0,0 +1,47 @@ +confirmToProceed()) { + return static::FAILURE; + } + + $deployer->retire($this->argument('name')); + + $this->components->info("Deployment [{$this->argument('name')}] was retired."); + + return static::SUCCESS; + } +} diff --git a/src/Console/DeploymentRollbackCommand.php b/src/Console/DeploymentRollbackCommand.php new file mode 100644 index 0000000..161f070 --- /dev/null +++ b/src/Console/DeploymentRollbackCommand.php @@ -0,0 +1,47 @@ +confirmToProceed()) { + return static::FAILURE; + } + + $deployer->rollback($this->argument('name')); + + $this->components->info("Deployment [{$this->argument('name')}] was rolled back."); + + return static::SUCCESS; + } +} diff --git a/src/Console/DeploymentStatusCommand.php b/src/Console/DeploymentStatusCommand.php new file mode 100644 index 0000000..7699f0b --- /dev/null +++ b/src/Console/DeploymentStatusCommand.php @@ -0,0 +1,54 @@ +prepare(); + + $records = $this->argument('name') + ? collect([$deployments->findOrFail($this->argument('name'))]) + : $deployments->all(); + + $this->table( + ['Name', 'Status', 'Alias', 'Active', 'Candidate', 'Previous'], + $records->map(fn (Deployment $deployment) => [ + $deployment->name, + $deployment->status->value, + $deployment->alias, + $deployment->activeIndex, + $deployment->candidateIndex ?? '-', + $deployment->previousIndex ?? '-', + ])->all() + ); + + return static::SUCCESS; + } +} diff --git a/src/Deployer.php b/src/Deployer.php index 40a04f5..e6fc7d7 100644 --- a/src/Deployer.php +++ b/src/Deployer.php @@ -61,7 +61,7 @@ public function provision( /** * Begin backfilling a provisioned candidate index. */ - public function beginBackfill(string $name): Deployment + public function backfill(string $name): Deployment { $deployment = $this->deployments->findOrFail($name); diff --git a/src/OpenSearchMigrationsServiceProvider.php b/src/OpenSearchMigrationsServiceProvider.php index a011007..7594a65 100644 --- a/src/OpenSearchMigrationsServiceProvider.php +++ b/src/OpenSearchMigrationsServiceProvider.php @@ -6,6 +6,13 @@ use DirectoryTree\OpenSearchAdapter\Indices\IndexManagerInterface as AdapterIndexManagerInterface; use DirectoryTree\OpenSearchClient\OpenSearchManager; use DirectoryTree\OpenSearchMigrations\Adapters\IndexManagerAdapter; +use DirectoryTree\OpenSearchMigrations\Console\DeploymentBackfillCommand; +use DirectoryTree\OpenSearchMigrations\Console\DeploymentCancelCommand; +use DirectoryTree\OpenSearchMigrations\Console\DeploymentCutoverCommand; +use DirectoryTree\OpenSearchMigrations\Console\DeploymentReadyCommand; +use DirectoryTree\OpenSearchMigrations\Console\DeploymentRetireCommand; +use DirectoryTree\OpenSearchMigrations\Console\DeploymentRollbackCommand; +use DirectoryTree\OpenSearchMigrations\Console\DeploymentStatusCommand; use DirectoryTree\OpenSearchMigrations\Console\FreshCommand; use DirectoryTree\OpenSearchMigrations\Console\MakeCommand; use DirectoryTree\OpenSearchMigrations\Console\MigrateCommand; @@ -31,6 +38,13 @@ class OpenSearchMigrationsServiceProvider extends ServiceProvider * @var array */ protected array $commands = [ + DeploymentBackfillCommand::class, + DeploymentCancelCommand::class, + DeploymentCutoverCommand::class, + DeploymentReadyCommand::class, + DeploymentRetireCommand::class, + DeploymentRollbackCommand::class, + DeploymentStatusCommand::class, MakeCommand::class, ResetCommand::class, FreshCommand::class, diff --git a/src/Repositories/DeploymentRepository.php b/src/Repositories/DeploymentRepository.php index b8113f7..a2975b2 100644 --- a/src/Repositories/DeploymentRepository.php +++ b/src/Repositories/DeploymentRepository.php @@ -6,9 +6,11 @@ use DirectoryTree\OpenSearchMigrations\Deployments\DeploymentException; use Illuminate\Database\Query\Builder; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use stdClass; class DeploymentRepository { @@ -62,6 +64,18 @@ public function findOrFail(string $name): Deployment ?? throw new DeploymentException("OpenSearch deployment [{$name}] does not exist."); } + /** + * Get all deployments. + * + * @return Collection + */ + public function all(): Collection + { + return $this->table()->orderBy('name')->get()->map( + fn (stdClass $record) => Deployment::fromRecord($record) + ); + } + /** * Delete a deployment record. */ diff --git a/tests/Integration/Console/DeploymentCommandsTest.php b/tests/Integration/Console/DeploymentCommandsTest.php new file mode 100644 index 0000000..fdab921 --- /dev/null +++ b/tests/Integration/Console/DeploymentCommandsTest.php @@ -0,0 +1,190 @@ +toContain( + 'opensearch:deploy:backfill', + 'opensearch:deploy:cancel', + 'opensearch:deploy:cutover', + 'opensearch:deploy:ready', + 'opensearch:deploy:retire', + 'opensearch:deploy:rollback', + 'opensearch:deploy:status', + ); +}); + +it('begins backfilling a deployment', function (): void { + $deployment = Deployment::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: CarbonImmutable::now(), + )->beginBackfill(); + + $deployer = Mockery::mock(Deployer::class); + $deployer->shouldReceive('backfill')->once()->with('posts')->andReturn($deployment); + app()->instance(Deployer::class, $deployer); + + $command = new DeploymentBackfillCommand; + $command->setLaravel(app()); + + expect($command->run(new ArrayInput([ + 'name' => 'posts', + '--force' => true, + ]), new NullOutput))->toBe(0); +}); + +it('marks a deployment ready', function (): void { + $deployment = Deployment::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: CarbonImmutable::now(), + )->beginBackfill()->markReady(CarbonImmutable::now()); + + $deployer = Mockery::mock(Deployer::class); + $deployer->shouldReceive('markReady')->once()->with('posts')->andReturn($deployment); + app()->instance(Deployer::class, $deployer); + + $command = new DeploymentReadyCommand; + $command->setLaravel(app()); + + expect($command->run(new ArrayInput([ + 'name' => 'posts', + '--force' => true, + ]), new NullOutput))->toBe(0); +}); + +it('cuts over a deployment', function (): void { + $deployment = Deployment::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: CarbonImmutable::now(), + )->beginBackfill()->markReady(CarbonImmutable::now()); + + $deployer = Mockery::mock(Deployer::class); + $deployer->shouldReceive('cutover')->once()->with('posts')->andReturn($deployment); + app()->instance(Deployer::class, $deployer); + + $command = new DeploymentCutoverCommand; + $command->setLaravel(app()); + + expect($command->run(new ArrayInput([ + 'name' => 'posts', + '--force' => true, + ]), new NullOutput))->toBe(0); +}); + +it('rolls back a deployment', function (): void { + $deployment = Deployment::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: CarbonImmutable::now(), + )->beginBackfill()->markReady(CarbonImmutable::now()); + + $deployer = Mockery::mock(Deployer::class); + $deployer->shouldReceive('rollback')->once()->with('posts')->andReturn($deployment); + app()->instance(Deployer::class, $deployer); + + $command = new DeploymentRollbackCommand; + $command->setLaravel(app()); + + expect($command->run(new ArrayInput([ + 'name' => 'posts', + '--force' => true, + ]), new NullOutput))->toBe(0); +}); + +it('cancels a deployment', function (): void { + $deployment = Deployment::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: CarbonImmutable::now(), + ); + + $deployer = Mockery::mock(Deployer::class); + $deployer->shouldReceive('cancel')->once()->with('posts')->andReturn($deployment); + app()->instance(Deployer::class, $deployer); + + $command = new DeploymentCancelCommand; + $command->setLaravel(app()); + + expect($command->run(new ArrayInput([ + 'name' => 'posts', + '--force' => true, + ]), new NullOutput))->toBe(0); +}); + +it('retires a deployment', function (): void { + $deployment = Deployment::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: CarbonImmutable::now(), + ); + + $deployer = Mockery::mock(Deployer::class); + $deployer->shouldReceive('retire')->once()->with('posts')->andReturn($deployment); + app()->instance(Deployer::class, $deployer); + + $command = new DeploymentRetireCommand; + $command->setLaravel(app()); + + expect($command->run(new ArrayInput([ + 'name' => 'posts', + '--force' => true, + ]), new NullOutput))->toBe(0); +}); + +it('shows deployment status', function (): void { + $deployments = app(DeploymentRepository::class); + $deployments->prepare(); + $deployments->save(Deployment::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: CarbonImmutable::now(), + )); + + $command = new DeploymentStatusCommand; + $command->setLaravel(app()); + + $output = new BufferedOutput; + + expect($command->run(new ArrayInput([]), $output))->toBe(0) + ->and($output->fetch())->toContain( + 'posts', + 'provisioned', + 'posts_search', + 'posts_blue', + 'posts_green', + ); +}); diff --git a/tests/Integration/Deployments/DeployerTest.php b/tests/Integration/Deployments/DeployerTest.php index ae28d40..d6763a4 100644 --- a/tests/Integration/Deployments/DeployerTest.php +++ b/tests/Integration/Deployments/DeployerTest.php @@ -61,7 +61,7 @@ ->and($deployment->status)->toBe(DeploymentStatus::Provisioned) ->and($deployment->writeIndexes())->toBe(['test_posts_search']); - $deployment = $manager->beginBackfill('posts'); + $deployment = $manager->backfill('posts'); expect($deployment->status)->toBe(DeploymentStatus::Backfilling) ->and($deployment->writeIndexes())->toBe([ @@ -107,7 +107,7 @@ $manager = new Deployer($repository, $indexes, $adapterIndexes, $openSearch); - expect($manager->beginBackfill('posts')->status)->toBe(DeploymentStatus::Backfilling); + expect($manager->backfill('posts')->status)->toBe(DeploymentStatus::Backfilling); expect($manager->markReady('posts')->status)->toBe(DeploymentStatus::Ready); $deployment = $manager->cutover('posts');