diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index d71b64563b..b01bbfa5ed 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -484,6 +484,7 @@ class DatabasesController extends Controller 'limits_cpus' => ['type' => 'string', 'description' => 'CPU limit of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], + 'instant_deploy' => ['type' => 'boolean', 'description' => 'Restart the database after the update. Requires the deploy ability.'], 'postgres_user' => ['type' => 'string', 'description' => 'PostgreSQL user'], 'postgres_password' => ['type' => 'string', 'description' => 'PostgreSQL password'], 'postgres_db' => ['type' => 'string', 'description' => 'PostgreSQL database'], @@ -571,6 +572,7 @@ class DatabasesController extends Controller 'limits_cpus' => 'string', 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', + 'instant_deploy' => 'boolean', ]); if ($validator->fails()) { @@ -580,6 +582,7 @@ class DatabasesController extends Controller ], 422); } $uuid = $request->uuid; + $instantDeploy = $request->boolean('instant_deploy'); removeUnnecessaryFieldsFromRequest($request); $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); if (! $database) { @@ -588,6 +591,11 @@ class DatabasesController extends Controller $this->authorize('update', $database); + if ($instantDeploy) { + abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); + $this->authorize('manage', $database); + } + if ($request->is_public && $request->public_port) { if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) { return response()->json(['message' => 'Public port already used by another database.'], 400); @@ -811,6 +819,17 @@ class DatabasesController extends Controller 'errors' => $errors, ], 422); } + $restartReservation = null; + if ($instantDeploy) { + $prerequisiteError = StartDatabase::prerequisiteError($database); + if ($prerequisiteError !== null) { + return response()->json(['message' => $prerequisiteError], 422); + } + $restartReservation = StartDatabase::reserveOperation($database); + if ($restartReservation === null) { + return response()->json(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE], 409); + } + } $whatToDoWithDatabaseProxy = null; if ($request->is_public === false && $database->is_public === true) { $whatToDoWithDatabaseProxy = 'stop'; @@ -821,12 +840,22 @@ class DatabasesController extends Controller // Only update database fields, not backup configuration $database->update($request->only($allowedFields)); + // The Redis password is stored in the REDIS_PASSWORD variable, not in a column. + if ($database instanceof StandaloneRedis && filled($request->redis_password)) { + $database->runtime_environment_variables()->updateOrCreate( + ['key' => 'REDIS_PASSWORD'], + ['value' => $request->redis_password, 'resourceable_id' => $database->id] + ); + } if ($whatToDoWithDatabaseProxy === 'start') { StartDatabaseProxy::dispatch($database); } elseif ($whatToDoWithDatabaseProxy === 'stop') { StopDatabaseProxy::dispatch($database); } + if ($restartReservation !== null) { + StartDatabase::dispatchReserved(RestartDatabase::class, $database, $restartReservation); + } auditLog('api.database.updated', [ 'team_id' => $teamId, diff --git a/openapi.json b/openapi.json index ec08efd153..d25e696bb5 100644 --- a/openapi.json +++ b/openapi.json @@ -7077,6 +7077,10 @@ "type": "integer", "description": "CPU shares of the database" }, + "instant_deploy": { + "type": "boolean", + "description": "Restart the database after the update. Requires the deploy ability." + }, "postgres_user": { "type": "string", "description": "PostgreSQL user" diff --git a/openapi.yaml b/openapi.yaml index 2871e12613..c28689096f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4547,6 +4547,9 @@ paths: limits_cpu_shares: type: integer description: 'CPU shares of the database' + instant_deploy: + type: boolean + description: 'Restart the database after the update. Requires the deploy ability.' postgres_user: type: string description: 'PostgreSQL user' diff --git a/tests/Feature/DatabaseUpdateApiTest.php b/tests/Feature/DatabaseUpdateApiTest.php new file mode 100644 index 0000000000..b6c65041e9 --- /dev/null +++ b/tests/Feature/DatabaseUpdateApiTest.php @@ -0,0 +1,84 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->database = create_standalone_redis($environment->id, $destination, ['redis_password' => 'OldPassword123']); + + Queue::fake(); +}); + +function patchDatabase(array $payload, array $abilities = ['write', 'deploy']) +{ + $plainTextToken = Str::random(40); + $token = test()->user->tokens()->create([ + 'name' => 'database-update-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => test()->team->id, + ]); + + return test()->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]) + ->patchJson('/api/v1/databases/'.test()->database->uuid, $payload); +} + +test('PATCH changes the Redis password', function () { + patchDatabase(['redis_password' => 'NewPassword456'])->assertOk(); + + $database = StandaloneRedis::query()->findOrFail($this->database->id); + expect($database->redis_password)->toBe('NewPassword456') + ->and($database->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->count())->toBe(1); +}); + +test('PATCH with instant_deploy restarts the database after the update', function () { + patchDatabase(['name' => 'renamed', 'instant_deploy' => true])->assertOk(); + + expect($this->database->refresh()->name)->toBe('renamed'); + RestartDatabase::assertPushed(1); +}); + +test('PATCH without instant_deploy does not restart the database', function () { + patchDatabase(['name' => 'renamed', 'instant_deploy' => false], ['write'])->assertOk(); + + RestartDatabase::assertNotPushed(); +}); + +test('PATCH with instant_deploy needs the deploy ability', function () { + patchDatabase(['name' => 'renamed', 'instant_deploy' => true], ['write'])->assertForbidden(); + + expect($this->database->refresh()->name)->not->toBe('renamed'); + RestartDatabase::assertNotPushed(); +}); + +test('PATCH rejects an instant_deploy value that is not a boolean', function () { + patchDatabase(['instant_deploy' => 'abc']) + ->assertUnprocessable() + ->assertJsonValidationErrors('instant_deploy'); + + RestartDatabase::assertNotPushed(); +});