Skip to content

Commit 26747c0

Browse files
committed
Fail closed schedule mutations on bootstrap blockers
Schedule writes now share the existing rollout-safety bootstrap gate, so blocked nodes stop creating, mutating, triggering, or backfilling schedules while read-only schedule inspection stays available for operator recovery.
1 parent 573d4e3 commit 26747c0

2 files changed

Lines changed: 142 additions & 13 deletions

File tree

‎routes/api.php‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
use App\Http\Middleware\ControlPlaneVersionResolver;
1919
use App\Http\Middleware\NamespaceResolver;
2020
use App\Http\Middleware\RequireRole;
21-
use App\Http\Middleware\RequireWorkflowBootstrapReady;
2221
use App\Http\Middleware\RequireTopologyRoles;
22+
use App\Http\Middleware\RequireWorkflowBootstrapReady;
2323
use App\Http\Middleware\WorkerProtocolVersionResolver;
2424
use Illuminate\Support\Facades\Route;
2525

@@ -170,17 +170,22 @@
170170
});
171171

172172
// ── Schedules ────────────────────────────────────────────────────
173-
Route::prefix('schedules')->middleware([$operator, $cpv, $httpControl, $ns])->group(function () {
174-
Route::get('/', [ScheduleController::class, 'index']);
175-
Route::post('/', [ScheduleController::class, 'store']);
176-
Route::get('/{scheduleId}', [ScheduleController::class, 'show']);
177-
Route::get('/{scheduleId}/history', [ScheduleController::class, 'history']);
178-
Route::put('/{scheduleId}', [ScheduleController::class, 'update']);
179-
Route::delete('/{scheduleId}', [ScheduleController::class, 'destroy']);
180-
Route::post('/{scheduleId}/pause', [ScheduleController::class, 'pause']);
181-
Route::post('/{scheduleId}/resume', [ScheduleController::class, 'resume']);
182-
Route::post('/{scheduleId}/trigger', [ScheduleController::class, 'trigger']);
183-
Route::post('/{scheduleId}/backfill', [ScheduleController::class, 'backfill']);
173+
Route::prefix('schedules')->group(function () use ($operator, $cpv, $httpControl, $workflowBootstrap, $ns) {
174+
Route::middleware([$operator, $cpv, $httpControl, $ns])->group(function () {
175+
Route::get('/', [ScheduleController::class, 'index']);
176+
Route::get('/{scheduleId}', [ScheduleController::class, 'show']);
177+
Route::get('/{scheduleId}/history', [ScheduleController::class, 'history']);
178+
});
179+
180+
Route::middleware([$operator, $cpv, $httpControl, $workflowBootstrap, $ns])->group(function () {
181+
Route::post('/', [ScheduleController::class, 'store']);
182+
Route::put('/{scheduleId}', [ScheduleController::class, 'update']);
183+
Route::delete('/{scheduleId}', [ScheduleController::class, 'destroy']);
184+
Route::post('/{scheduleId}/pause', [ScheduleController::class, 'pause']);
185+
Route::post('/{scheduleId}/resume', [ScheduleController::class, 'resume']);
186+
Route::post('/{scheduleId}/trigger', [ScheduleController::class, 'trigger']);
187+
Route::post('/{scheduleId}/backfill', [ScheduleController::class, 'backfill']);
188+
});
184189
});
185190

186191
// ── Search Attributes ────────────────────────────────────────────

‎tests/Feature/WorkflowBootstrapRouteGatingTest.php‎

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
use App\Support\ControlPlaneProtocol;
99
use App\Support\WorkerProtocol;
1010
use Illuminate\Foundation\Testing\RefreshDatabase;
11+
use Illuminate\Support\Facades\DB;
12+
use Illuminate\Testing\TestResponse;
1113
use Tests\TestCase;
14+
use Workflow\V2\Models\WorkflowSchedule;
1215
use Workflow\V2\Support\WorkerCompatibilityFleet;
1316

1417
class WorkflowBootstrapRouteGatingTest extends TestCase
@@ -84,6 +87,52 @@ public function test_bootstrap_gate_runs_before_namespace_resolution_on_hosted_r
8487
->assertJsonMissing(['reason' => 'namespace_not_found']);
8588
}
8689

90+
public function test_pending_rollout_safety_migrations_block_schedule_mutation_routes(): void
91+
{
92+
$this->createSchedule('bootstrap-blocked-schedule');
93+
$this->blockWorkflowBootstrap();
94+
95+
foreach ($this->blockedScheduleMutations() as [$method, $path, $body]) {
96+
$response = $this->sendControlPlaneJson($method, $path, $body);
97+
98+
$response->assertStatus(503)
99+
->assertHeader(ControlPlaneProtocol::HEADER, ControlPlaneProtocol::VERSION)
100+
->assertJsonPath('reason', 'workflow_v2_blocked')
101+
->assertJsonPath('blocked_by.0', 'migrations');
102+
}
103+
}
104+
105+
public function test_schedule_read_routes_remain_available_when_bootstrap_is_blocked(): void
106+
{
107+
$this->createSchedule('bootstrap-readable-schedule');
108+
$this->blockWorkflowBootstrap();
109+
110+
$this->withHeaders($this->controlHeaders('operator-token'))
111+
->getJson('/api/schedules')
112+
->assertOk()
113+
->assertJsonPath('schedules.0.schedule_id', 'bootstrap-readable-schedule');
114+
115+
$this->withHeaders($this->controlHeaders('operator-token'))
116+
->getJson('/api/schedules/bootstrap-readable-schedule')
117+
->assertOk()
118+
->assertJsonPath('schedule_id', 'bootstrap-readable-schedule');
119+
}
120+
121+
public function test_bootstrap_gate_runs_before_namespace_resolution_on_schedule_mutation_routes(): void
122+
{
123+
$this->blockWorkflowBootstrap();
124+
125+
$response = $this->sendControlPlaneJson('POST', '/api/schedules', [
126+
'schedule_id' => 'ghost-namespace-schedule',
127+
'spec' => ['cron_expressions' => ['0 * * * *']],
128+
'action' => ['workflow_type' => 'GhostNamespaceWorkflow'],
129+
], 'ghost-namespace');
130+
131+
$response->assertStatus(503)
132+
->assertJsonPath('reason', 'workflow_v2_blocked')
133+
->assertJsonMissing(['reason' => 'namespace_not_found']);
134+
}
135+
87136
public function test_worker_registration_can_recover_fail_mode_compatibility_health(): void
88137
{
89138
config()->set('queue.default', 'redis');
@@ -111,11 +160,74 @@ public function test_worker_registration_can_recover_fail_mode_compatibility_hea
111160

112161
private function blockWorkflowBootstrap(): void
113162
{
114-
\Illuminate\Support\Facades\DB::table('migrations')
163+
DB::table('migrations')
115164
->where('migration', '2026_04_21_000300_add_workflow_definition_fingerprints_to_worker_registrations')
116165
->delete();
117166
}
118167

168+
/**
169+
* @return list<array{0: string, 1: string, 2: array<string, mixed>}>
170+
*/
171+
private function blockedScheduleMutations(): array
172+
{
173+
return [
174+
[
175+
'POST',
176+
'/api/schedules',
177+
[
178+
'schedule_id' => 'bootstrap-created-schedule',
179+
'spec' => ['cron_expressions' => ['0 * * * *']],
180+
'action' => ['workflow_type' => 'BootstrapWorkflow'],
181+
],
182+
],
183+
[
184+
'PUT',
185+
'/api/schedules/bootstrap-blocked-schedule',
186+
['note' => 'Blocked update'],
187+
],
188+
[
189+
'DELETE',
190+
'/api/schedules/bootstrap-blocked-schedule',
191+
[],
192+
],
193+
[
194+
'POST',
195+
'/api/schedules/bootstrap-blocked-schedule/pause',
196+
['note' => 'Blocked pause'],
197+
],
198+
[
199+
'POST',
200+
'/api/schedules/bootstrap-blocked-schedule/resume',
201+
['note' => 'Blocked resume'],
202+
],
203+
[
204+
'POST',
205+
'/api/schedules/bootstrap-blocked-schedule/trigger',
206+
[],
207+
],
208+
[
209+
'POST',
210+
'/api/schedules/bootstrap-blocked-schedule/backfill',
211+
[
212+
'start_time' => '2026-04-18T10:00:00Z',
213+
'end_time' => '2026-04-18T11:00:00Z',
214+
],
215+
],
216+
];
217+
}
218+
219+
private function createSchedule(string $scheduleId): WorkflowSchedule
220+
{
221+
return WorkflowSchedule::query()->create([
222+
'schedule_id' => $scheduleId,
223+
'namespace' => 'default',
224+
'spec' => ['cron_expressions' => ['0 * * * *'], 'timezone' => 'UTC'],
225+
'action' => ['workflow_type' => 'BootstrapWorkflow'],
226+
'overlap_policy' => 'skip',
227+
'status' => 'active',
228+
]);
229+
}
230+
119231
/**
120232
* @return array<string, string>
121233
*/
@@ -139,4 +251,16 @@ private function controlHeaders(string $token, string $namespace = 'default'): a
139251
ControlPlaneProtocol::HEADER => ControlPlaneProtocol::VERSION,
140252
];
141253
}
254+
255+
/**
256+
* @param array<string, mixed> $body
257+
*/
258+
private function sendControlPlaneJson(
259+
string $method,
260+
string $path,
261+
array $body = [],
262+
string $namespace = 'default',
263+
): TestResponse {
264+
return $this->json($method, $path, $body, $this->controlHeaders('operator-token', $namespace));
265+
}
142266
}

0 commit comments

Comments
 (0)