diff --git a/README.md b/README.md index 3c26db1..db319c7 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,107 @@ 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'); +``` + +Provision a candidate 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->provision( + 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 inspection while writes continue targeting only the active alias: + +```php +$deployment->candidateIndex; +$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->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 +$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 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/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/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..e6fc7d7 --- /dev/null +++ b/src/Deployer.php @@ -0,0 +1,257 @@ +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::provisioned( + name: $name, + alias: $prefixedAlias, + activeIndex: $activeIndex, + candidateIndex: MigrationPrefix::index($candidate), + now: Date::now(), + ) + ); + } + + /** + * Begin backfilling a provisioned candidate index. + */ + public function backfill(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. + */ + public function markReady(string $name): Deployment + { + $deployment = $this->deployments->findOrFail($name); + + 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( + $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..6ddd230 --- /dev/null +++ b/src/Deployments/Deployment.php @@ -0,0 +1,239 @@ +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, + ) {} + + /** + * 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. + */ + 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 + { + $candidate = in_array($this->status, [ + DeploymentStatus::Backfilling, + DeploymentStatus::Ready, + ], true) ? $this->candidateIndex : null; + + return array_values(array_unique(array_filter([ + $this->alias, + $candidate, + $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 @@ + */ protected array $commands = [ + DeploymentBackfillCommand::class, + DeploymentCancelCommand::class, + DeploymentCutoverCommand::class, + DeploymentReadyCommand::class, + DeploymentRetireCommand::class, + DeploymentRollbackCommand::class, + DeploymentStatusCommand::class, MakeCommand::class, ResetCommand::class, FreshCommand::class, @@ -45,6 +60,7 @@ class OpenSearchMigrationsServiceProvider extends ServiceProvider public function register(): void { $this->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 +78,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 +99,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..a2975b2 --- /dev/null +++ b/src/Repositories/DeploymentRepository.php @@ -0,0 +1,139 @@ +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."); + } + + /** + * 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. + */ + 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/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/Console/FreshCommandTest.php b/tests/Integration/Console/FreshCommandTest.php index 3befac8..2713bc8 100644 --- a/tests/Integration/Console/FreshCommandTest.php +++ b/tests/Integration/Console/FreshCommandTest.php @@ -1,16 +1,30 @@ prepare(); + $deployments->save(Deployment::provisioned( + 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..501cf9a 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::provisioned( + 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..7afa453 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::provisioned( + 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..d6763a4 --- /dev/null +++ b/tests/Integration/Deployments/DeployerTest.php @@ -0,0 +1,286 @@ +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->provision('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::Provisioned) + ->and($deployment->writeIndexes())->toBe(['test_posts_search']); + + $deployment = $manager->backfill('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::provisioned( + 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->backfill('posts')->status)->toBe(DeploymentStatus::Backfilling); + 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('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::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )->beginBackfill()->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::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )->beginBackfill()->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::provisioned( + 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::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )->beginBackfill()->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::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: Date::now(), + )->beginBackfill()->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..43c72f8 --- /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::provisioned( + 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::Provisioned) + ->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::provisioned( + 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')->beginBackfill()->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::provisioned( + 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..59d503b --- /dev/null +++ b/tests/Unit/Deployments/DeploymentTest.php @@ -0,0 +1,68 @@ +writeIndexes())->toBe([ + 'posts_search', + ]); + + $deployment = $deployment->beginBackfill()->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::provisioned( + name: 'posts', + alias: 'posts_search', + activeIndex: 'posts_blue', + candidateIndex: 'posts_green', + now: $startedAt, + ); + + expect($deployment->id)->toBeNull() + ->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) + ->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'); +});