Skip to content

Commit 3d3e93e

Browse files
authored
Fix duplicate cronjob during sync (#955)
* Fix duplicate cronjob during sync * fix commented cronjobs * fix normalize
1 parent bbe3495 commit 3d3e93e

File tree

3 files changed

+215
-17
lines changed

3 files changed

+215
-17
lines changed

app/Actions/CronJob/SyncCronJobs.php

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,17 @@ private function syncUserCronJobs(Server $server, string $user): void
2929
// Get existing cronjobs from server for this user
3030
$crontabOutput = $this->getUserCrontab($server, $user);
3131

32-
// Get all Vito-managed cronjobs for this user
32+
// Get all Vito-managed cronjobs for this user (including both server-level and site-level)
3333
$vitoCronJobs = $server->cronJobs()
3434
->where('user', $user)
35-
->where('site_id', null) // Only server-level cronjobs
3635
->get();
3736

37+
// Filter only server-level cronjobs (site_id = null) for status updates
38+
$serverLevelCronJobs = $vitoCronJobs->where('site_id', null);
39+
3840
if (empty($crontabOutput)) {
39-
// If crontab is empty, mark all Vito cronjobs as disabled
40-
foreach ($vitoCronJobs as $cronJob) {
41+
// If crontab is empty, mark all server-level Vito cronjobs as disabled
42+
foreach ($serverLevelCronJobs as $cronJob) {
4143
if ($cronJob->status === CronjobStatus::READY) {
4244
$cronJob->update(['status' => CronjobStatus::DISABLED]);
4345
}
@@ -72,34 +74,50 @@ private function syncUserCronJobs(Server $server, string $user): void
7274
continue;
7375
}
7476

75-
$frequency = implode(' ', array_slice($parts, 0, 5));
76-
$command = $parts[5];
77+
// Validate that the first 5 parts look like cron time fields
78+
// Valid cron fields contain: numbers, *, -, /, and ,
79+
$isValidCronFormat = true;
80+
for ($i = 0; $i < 5; $i++) {
81+
if (! preg_match('/^[\d\*\-\/,]+$/', $parts[$i])) {
82+
$isValidCronFormat = false;
83+
break;
84+
}
85+
}
86+
87+
if (! $isValidCronFormat) {
88+
continue;
89+
}
90+
91+
$frequency = $this->normalizeFrequency(implode(' ', array_slice($parts, 0, 5)));
92+
$command = $this->normalizeCommand($parts[5]);
7793

7894
$serverCronJobs[] = [
7995
'frequency' => $frequency,
8096
'command' => $command,
8197
'commented' => $isCommented,
8298
];
8399

84-
// Check if this matches a Vito-managed cronjob
100+
// Check if this matches any Vito-managed cronjob (including site-level ones)
85101
$matchingCronJob = $vitoCronJobs->first(function ($cronJob) use ($frequency, $command) {
86-
return $cronJob->frequency === $frequency && $cronJob->command === $command;
102+
return $this->normalizeFrequency($cronJob->frequency) === $frequency && $this->normalizeCommand($cronJob->command) === $command;
87103
});
88104

89105
if ($matchingCronJob) {
90106
$foundCronJobs[] = $matchingCronJob->id;
91107

92-
// Update status based on comment state
93-
if ($isCommented && $matchingCronJob->status === CronjobStatus::READY) {
94-
$matchingCronJob->update(['status' => CronjobStatus::DISABLED]);
95-
} elseif (! $isCommented && $matchingCronJob->status === CronjobStatus::DISABLED) {
96-
$matchingCronJob->update(['status' => CronjobStatus::READY]);
108+
// Update status based on comment state (only for server-level cronjobs)
109+
if ($matchingCronJob->site_id === null) {
110+
if ($isCommented && $matchingCronJob->status === CronjobStatus::READY) {
111+
$matchingCronJob->update(['status' => CronjobStatus::DISABLED]);
112+
} elseif (! $isCommented && $matchingCronJob->status === CronjobStatus::DISABLED) {
113+
$matchingCronJob->update(['status' => CronjobStatus::READY]);
114+
}
97115
}
98116
}
99117
}
100118

101-
// Mark Vito cronjobs that are no longer on the server as disabled
102-
foreach ($vitoCronJobs as $cronJob) {
119+
// Mark server-level Vito cronjobs that are no longer on the server as disabled
120+
foreach ($serverLevelCronJobs as $cronJob) {
103121
if (! in_array($cronJob->id, $foundCronJobs) && $cronJob->status === CronjobStatus::READY) {
104122
$cronJob->update(['status' => CronjobStatus::DISABLED]);
105123
}
@@ -108,7 +126,7 @@ private function syncUserCronJobs(Server $server, string $user): void
108126
// Create new cronjobs for manually created ones (not in Vito)
109127
foreach ($serverCronJobs as $cronJobData) {
110128
$isVitoManaged = $vitoCronJobs->contains(function ($cronJob) use ($cronJobData) {
111-
return $cronJob->frequency === $cronJobData['frequency'] && $cronJob->command === $cronJobData['command'];
129+
return $this->normalizeFrequency($cronJob->frequency) === $cronJobData['frequency'] && $this->normalizeCommand($cronJob->command) === $cronJobData['command'];
112130
});
113131

114132
if (! $isVitoManaged) {
@@ -124,6 +142,18 @@ private function syncUserCronJobs(Server $server, string $user): void
124142
}
125143
}
126144

145+
private function normalizeFrequency(string $frequency): string
146+
{
147+
// Normalize frequency by ensuring single spaces between parts
148+
return preg_replace('/\s+/', ' ', trim($frequency));
149+
}
150+
151+
private function normalizeCommand(string $command): string
152+
{
153+
// Normalize command by ensuring single spaces between parts
154+
return preg_replace('/\s+/', ' ', trim($command));
155+
}
156+
127157
/**
128158
* @throws SSHError
129159
*/

app/Models/CronJob.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
/**
1111
* @property int $server_id
12-
* @property int $site_id
12+
* @property ?int $site_id
1313
* @property string $command
1414
* @property string $user
1515
* @property string $frequency

tests/Feature/SyncCronjobTest.php

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,4 +353,172 @@ public function test_sync_handles_mixed_scenarios_with_deletions(): void
353353
'site_id' => null,
354354
]);
355355
}
356+
357+
public function test_sync_normalizes_frequency_with_extra_spaces(): void
358+
{
359+
// Create a cronjob with normal spacing
360+
$existingCronJob = CronJob::factory()->create([
361+
'server_id' => $this->server->id,
362+
'user' => 'root',
363+
'command' => '/usr/bin/backup.sh',
364+
'frequency' => '5 15 * * *',
365+
'status' => CronjobStatus::READY,
366+
'site_id' => null,
367+
]);
368+
369+
// Mock SSH to return the same cronjob with extra spaces
370+
SSH::fake('5 15 * * * /usr/bin/backup.sh');
371+
372+
$this->actingAs($this->user)
373+
->post(route('cronjobs.sync', $this->server))
374+
->assertRedirect()
375+
->assertSessionHas('success', 'Cron jobs synced successfully.');
376+
377+
// Should not create duplicate, existing cronjob should remain
378+
$cronJobs = CronJob::where('server_id', $this->server->id)
379+
->where('command', '/usr/bin/backup.sh')
380+
->where('site_id', null)
381+
->get();
382+
383+
// Should only have the one existing cronjob for each user (root + vito = 2 total)
384+
$this->assertCount(2, $cronJobs);
385+
386+
// The original cronjob should still be ready
387+
$existingCronJob->refresh();
388+
$this->assertEquals(CronjobStatus::READY, $existingCronJob->status);
389+
}
390+
391+
public function test_sync_recognizes_site_level_cronjobs(): void
392+
{
393+
// Create a site-level cronjob with the same command as what will be on the server
394+
$siteCronJob = CronJob::factory()->create([
395+
'server_id' => $this->server->id,
396+
'user' => 'root',
397+
'command' => '/usr/bin/backup.sh',
398+
'frequency' => '5 15 * * *',
399+
'status' => CronjobStatus::READY,
400+
'site_id' => $this->site->id,
401+
]);
402+
403+
// Mock SSH to return a cronjob with the same frequency and command
404+
SSH::fake('5 15 * * * /usr/bin/backup.sh');
405+
406+
$countBefore = CronJob::where('server_id', $this->server->id)
407+
->where('command', '/usr/bin/backup.sh')
408+
->count();
409+
410+
$this->actingAs($this->user)
411+
->post(route('cronjobs.sync', $this->server))
412+
->assertRedirect()
413+
->assertSessionHas('success', 'Cron jobs synced successfully.');
414+
415+
$countAfter = CronJob::where('server_id', $this->server->id)
416+
->where('command', '/usr/bin/backup.sh')
417+
->count();
418+
419+
// Before fix: would create duplicate with site_id = null
420+
// After fix: recognizes site-level cronjob and doesn't duplicate it, only creates for vito user
421+
// countBefore = 1 (site-level), countAfter should be 2 (site-level + vito user)
422+
$this->assertEquals($countBefore + 1, $countAfter);
423+
424+
// The site-level cronjob should remain unchanged
425+
$siteCronJob->refresh();
426+
$this->assertEquals($this->site->id, $siteCronJob->site_id);
427+
$this->assertEquals(CronjobStatus::READY, $siteCronJob->status);
428+
}
429+
430+
public function test_sync_handles_frequency_with_mixed_spacing_in_db(): void
431+
{
432+
// Create a cronjob with extra spaces in the frequency (simulating old data)
433+
$existingCronJob = CronJob::factory()->create([
434+
'server_id' => $this->server->id,
435+
'user' => 'root',
436+
'command' => '/usr/bin/backup.sh',
437+
'frequency' => '5 15 * * *', // Double spaces
438+
'status' => CronjobStatus::READY,
439+
'site_id' => null,
440+
]);
441+
442+
// Mock SSH to return the same cronjob with normalized spacing
443+
SSH::fake('5 15 * * * /usr/bin/backup.sh');
444+
445+
$this->actingAs($this->user)
446+
->post(route('cronjobs.sync', $this->server))
447+
->assertRedirect()
448+
->assertSessionHas('success', 'Cron jobs synced successfully.');
449+
450+
// Should not create duplicate
451+
$cronJobs = CronJob::where('server_id', $this->server->id)
452+
->where('command', '/usr/bin/backup.sh')
453+
->where('site_id', null)
454+
->get();
455+
456+
// Should only have the one existing cronjob for each user (root + vito = 2 total)
457+
$this->assertCount(2, $cronJobs);
458+
459+
// The original cronjob should still be ready
460+
$existingCronJob->refresh();
461+
$this->assertEquals(CronjobStatus::READY, $existingCronJob->status);
462+
}
463+
464+
public function test_sync_ignores_crontab_documentation_comments(): void
465+
{
466+
// Mock SSH to return crontab with documentation comments (like the default crontab header)
467+
$crontabWithComments = '# Edit this file to introduce tasks to be run by cron.
468+
#
469+
# Each task to run has to be defined through a single line
470+
# m h dom mon dow command
471+
#
472+
0 2 * * * /usr/bin/backup.sh';
473+
474+
SSH::fake($crontabWithComments);
475+
476+
$this->actingAs($this->user)
477+
->post(route('cronjobs.sync', $this->server))
478+
->assertRedirect()
479+
->assertSessionHas('success', 'Cron jobs synced successfully.');
480+
481+
// Should only create cronjobs for the actual cron line, not the documentation comments
482+
$cronJobs = CronJob::where('server_id', $this->server->id)->get();
483+
484+
// Should have 2 cronjobs (1 for root, 1 for vito), not 6 (which would include the comment lines)
485+
$this->assertCount(2, $cronJobs);
486+
487+
// Both should have the actual backup command
488+
$this->assertTrue($cronJobs->every(fn ($cronJob) => $cronJob->command === '/usr/bin/backup.sh'));
489+
}
490+
491+
public function test_sync_normalizes_command_with_extra_spaces(): void
492+
{
493+
// Create a cronjob with normal spacing in command
494+
$existingCronJob = CronJob::factory()->create([
495+
'server_id' => $this->server->id,
496+
'user' => 'root',
497+
'command' => 'ls -la',
498+
'frequency' => '* * * * *',
499+
'status' => CronjobStatus::READY,
500+
'site_id' => null,
501+
]);
502+
503+
// Mock SSH to return the same cronjob with extra spaces in command
504+
SSH::fake('* * * * * ls -la');
505+
506+
$this->actingAs($this->user)
507+
->post(route('cronjobs.sync', $this->server))
508+
->assertRedirect()
509+
->assertSessionHas('success', 'Cron jobs synced successfully.');
510+
511+
// Should not create duplicate, existing cronjob should remain
512+
$cronJobs = CronJob::where('server_id', $this->server->id)
513+
->where('site_id', null)
514+
->get();
515+
516+
// Should only have the one existing cronjob for each user (root + vito = 2 total)
517+
$this->assertCount(2, $cronJobs);
518+
519+
// The original cronjob should still be ready
520+
$existingCronJob->refresh();
521+
$this->assertEquals(CronjobStatus::READY, $existingCronJob->status);
522+
$this->assertEquals('ls -la', $existingCronJob->command);
523+
}
356524
}

0 commit comments

Comments
 (0)