Merge branch 'next' into next

This commit is contained in:
Vadko 2026-02-27 00:49:51 +02:00 committed by GitHub
commit cea4f4a49a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
130 changed files with 10849 additions and 806 deletions

View file

@ -23,19 +23,28 @@ Activate this skill when:
Use `search-docs` for detailed Pest 4 patterns and documentation.
## Basic Usage
## Test Directory Structure
### Creating Tests
- `tests/Feature/` and `tests/Unit/` — Legacy tests (keep, don't delete)
- `tests/v4/Feature/` — New feature tests (SQLite :memory: database)
- `tests/v4/Browser/` — Browser tests (Pest Browser Plugin + Playwright)
- `tests/Browser/` — Legacy Dusk browser tests (keep, don't delete)
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
New tests go in `tests/v4/`. The v4 suite uses SQLite :memory: with a schema dump (`database/schema/testing-schema.sql`) instead of running migrations.
### Test Organization
Do NOT remove tests without approval.
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
## Running Tests
### Basic Test Structure
- All v4 tests: `php artisan test --compact tests/v4/`
- Browser tests: `php artisan test --compact tests/v4/Browser/`
- Feature tests: `php artisan test --compact tests/v4/Feature/`
- Specific file: `php artisan test --compact tests/v4/Browser/LoginTest.php`
- Filter: `php artisan test --compact --filter=testName`
- Headed (see browser): `./vendor/bin/pest tests/v4/Browser/ --headed`
- Debug (pause on failure): `./vendor/bin/pest tests/v4/Browser/ --debug`
## Basic Test Structure
<code-snippet name="Basic Pest Test Example" lang="php">
@ -45,24 +54,10 @@ it('is true', function () {
</code-snippet>
### Running Tests
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
<code-snippet name="Pest Response Assertion" lang="php">
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
</code-snippet>
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
@ -75,7 +70,7 @@ Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
Use datasets for repetitive tests:
<code-snippet name="Pest Dataset Example" lang="php">
@ -88,73 +83,94 @@ it('has emails', function (string $email) {
</code-snippet>
## Pest 4 Features
## Browser Testing (Pest Browser Plugin + Playwright)
| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |
Browser tests use `pestphp/pest-plugin-browser` with Playwright. They run **outside Docker** — the plugin starts an in-process HTTP server and Playwright browser automatically.
### Browser Test Example
### Key Rules
Browser tests run in real browsers for full integration testing:
1. **Always use `RefreshDatabase`** — the in-process server uses SQLite :memory:
2. **Always seed `InstanceSettings::create(['id' => 0])` in `beforeEach`** — most pages crash without it
3. **Use `User::factory()` for auth tests** — create users with `id => 0` for root user
4. **No Dusk, no Selenium** — use `visit()`, `fill()`, `click()`, `assertSee()` from the Pest Browser API
5. **Place tests in `tests/v4/Browser/`**
6. **Views with bare `function` declarations** will crash on the second request in the same process — wrap with `function_exists()` guard if you encounter this
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
### Browser Test Template
<code-snippet name="Pest Browser Test Example" lang="php">
<code-snippet name="Coolify Browser Test Template" lang="php">
<?php
it('may reset the password', function () {
Notification::fake();
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
$this->actingAs(User::factory()->create());
uses(RefreshDatabase::class);
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
});
it('can visit the page', function () {
$page = visit('/login');
$page->assertSee('Login');
});
</code-snippet>
### Smoke Testing
### Browser Test with Form Interaction
Quickly validate multiple pages have no JavaScript errors:
<code-snippet name="Browser Test Form Example" lang="php">
it('fails login with invalid credentials', function () {
User::factory()->create([
'id' => 0,
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
$page = visit('/login');
$page->fill('email', 'random@email.com')
->fill('password', 'wrongpassword123')
->click('Login')
->assertSee('These credentials do not match our records');
});
</code-snippet>
### Visual Regression Testing
### Browser API Reference
Capture and compare screenshots to detect visual changes.
| Method | Purpose |
|--------|---------|
| `visit('/path')` | Navigate to a page |
| `->fill('field', 'value')` | Fill an input by name |
| `->click('Button Text')` | Click a button/link by text |
| `->assertSee('text')` | Assert visible text |
| `->assertDontSee('text')` | Assert text is not visible |
| `->assertPathIs('/path')` | Assert current URL path |
| `->assertSeeIn('.selector', 'text')` | Assert text in element |
| `->screenshot()` | Capture screenshot |
| `->debug()` | Pause test, keep browser open |
| `->wait(seconds)` | Wait N seconds |
### Test Sharding
### Debugging
Split tests across parallel processes for faster CI runs.
- Screenshots auto-saved to `tests/Browser/Screenshots/` on failure
- `->debug()` pauses and keeps browser open (press Enter to continue)
- `->screenshot()` captures state at any point
- `--headed` flag shows browser, `--debug` pauses on failure
### Architecture Testing
## SQLite Testing Setup
Pest 4 includes architecture testing (from Pest 3):
v4 tests use SQLite :memory: instead of PostgreSQL. Schema loaded from `database/schema/testing-schema.sql`.
### Regenerating the Schema
When migrations change, regenerate from the running PostgreSQL database:
```bash
docker exec coolify php artisan schema:generate-testing
```
## Architecture Testing
<code-snippet name="Architecture Test Example" lang="php">
@ -171,4 +187,7 @@ arch('controllers')
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests
- Forgetting `assertNoJavaScriptErrors()` in browser tests
- **Browser tests: forgetting `InstanceSettings::create(['id' => 0])` — most pages crash without it**
- **Browser tests: forgetting `RefreshDatabase` — SQLite :memory: starts empty**
- **Browser tests: views with bare `function` declarations crash on second request — wrap with `function_exists()` guard**

15
.env.testing Normal file
View file

@ -0,0 +1,15 @@
APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
APP_DEBUG=true
DB_CONNECTION=testing
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array
TELESCOPE_ENABLED=false
REDIS_HOST=127.0.0.1
SELF_HOSTED=true

View file

@ -1,86 +0,0 @@
name: Remove Labels and Assignees on Issue Close
on:
issues:
types: [closed]
pull_request:
types: [closed]
pull_request_target:
types: [closed]
permissions:
issues: write
pull-requests: write
jobs:
remove-labels-and-assignees:
runs-on: ubuntu-latest
steps:
- name: Remove labels and assignees
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
async function processIssue(issueNumber, isFromPR = false, prBaseBranch = null) {
try {
if (isFromPR && prBaseBranch !== 'v4.x') {
return;
}
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: issueNumber
});
const labelsToKeep = currentLabels
.filter(label => label.name === '⏱︎ Stale')
.map(label => label.name);
await github.rest.issues.setLabels({
owner,
repo,
issue_number: issueNumber,
labels: labelsToKeep
});
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber
});
if (issue.assignees && issue.assignees.length > 0) {
await github.rest.issues.removeAssignees({
owner,
repo,
issue_number: issueNumber,
assignees: issue.assignees.map(assignee => assignee.login)
});
}
} catch (error) {
if (error.status !== 404) {
console.error(`Error processing issue ${issueNumber}:`, error);
}
}
}
if (context.eventName === 'issues') {
await processIssue(context.payload.issue.number);
}
if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') {
const pr = context.payload.pull_request;
await processIssue(pr.number);
if (pr.merged && pr.base.ref === 'v4.x' && pr.body) {
const issueReferences = pr.body.match(/#(\d+)/g);
if (issueReferences) {
for (const reference of issueReferences) {
const issueNumber = parseInt(reference.substring(1));
await processIssue(issueNumber, true, pr.base.ref);
}
}
}
}

View file

@ -8,6 +8,7 @@ on:
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile

View file

@ -11,6 +11,7 @@ on:
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile

View file

@ -3,6 +3,12 @@ name: Generate Changelog
on:
push:
branches: [ v4.x ]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
workflow_dispatch:
permissions:

96
.github/workflows/pr-quality.yaml vendored Normal file
View file

@ -0,0 +1,96 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
# General Settings
max-failures: 3
# PR Branch Checks
allowed-target-branches: "next"
blocked-target-branches: ""
allowed-source-branches: ""
blocked-source-branches: |
main
master
v4.x
next
# PR Quality Checks
max-negative-reactions: 0
require-maintainer-can-modify: true
# PR Title Checks
require-conventional-title: true
# PR Description Checks
require-description: true
max-description-length: 0
max-emoji-count: 2
require-pr-template: true
require-linked-issue: false
blocked-terms: "STRAWBERRY"
blocked-issue-numbers: 8154
# Commit Message Checks
require-conventional-commits: false
blocked-commit-authors: "claude,copilot"
# File Checks
allowed-file-extensions: ""
allowed-paths: ""
blocked-paths: |
README.md
SECURITY.md
LICENSE
CODE_OF_CONDUCT.md
templates/service-templates-latest.json
templates/service-templates.json
require-final-newline: true
# User Health Checks
min-repo-merged-prs: 0
min-repo-merge-ratio: 0
min-global-merge-ratio: 30
global-merge-ratio-exclude-own: false
min-account-age: 10
# Exemptions
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
exempt-users: ""
exempt-bots: |
actions-user
dependabot[bot]
renovate[bot]
github-actions[bot]
exempt-draft-prs: false
exempt-label: "quality/exempt"
exempt-pr-label: ""
exempt-milestones: ""
exempt-pr-milestones: ""
exempt-all-milestones: false
exempt-all-pr-milestones: false
# PR Success Actions
success-add-pr-labels: "quality/verified"
# PR Failure Actions
close-pr: true
lock-pr: false
delete-branch: false
failure-pr-message: "This PR did not pass quality checks so it will be closed. If you believe this is a mistake please let us know."
failure-remove-pr-labels: ""
failure-remove-all-pr-labels: true
failure-add-pr-labels: "quality/rejected"

2
.gitignore vendored
View file

@ -38,3 +38,5 @@ docker/coolify-realtime/node_modules
.DS_Store
CHANGELOG.md
/.workspaces
tests/Browser/Screenshots
tests/v4/Browser/Screenshots

View file

@ -112,12 +112,52 @@ class StartDatabaseProxy
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
$nginxconf_base64 = base64_encode($nginxconf);
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
instant_remote_process([
"mkdir -p $configuration_dir",
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
try {
instant_remote_process([
"mkdir -p $configuration_dir",
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
} catch (\RuntimeException $e) {
if ($this->isNonTransientError($e->getMessage())) {
$database->update(['is_public' => false]);
$team = data_get($database, 'environment.project.team')
?? data_get($database, 'service.environment.project.team');
$team?->notify(
new \App\Notifications\Container\ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server,
)
);
ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
return;
}
throw $e;
}
}
private function isNonTransientError(string $message): bool
{
$nonTransientPatterns = [
'port is already allocated',
'address already in use',
'Bind for',
];
foreach ($nonTransientPatterns as $pattern) {
if (str_contains($message, $pattern)) {
return true;
}
}
return false;
}
}

View file

@ -207,6 +207,9 @@ class StartKeydb
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";

View file

@ -204,6 +204,9 @@ class StartRedis
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";

View file

@ -30,12 +30,14 @@ class InstallDocker
);
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($serverCert->ssl_certificate);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
remote_process($commands, $server);

View file

@ -32,7 +32,8 @@ class OpenApi extends Command
echo $process->output();
$yaml = file_get_contents('openapi.yaml');
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT);
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n";
file_put_contents('openapi.json', $json);
echo "Converted OpenAPI YAML to JSON.\n";
}

View file

@ -0,0 +1,222 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class GenerateTestingSchema extends Command
{
protected $signature = 'schema:generate-testing {--connection=pgsql : The database connection to read from}';
protected $description = 'Generate SQLite testing schema from the PostgreSQL database';
private array $typeMap = [
'/\bbigint\b/' => 'INTEGER',
'/\binteger\b/' => 'INTEGER',
'/\bsmallint\b/' => 'INTEGER',
'/\bboolean\b/' => 'INTEGER',
'/character varying\(\d+\)/' => 'TEXT',
'/timestamp\(\d+\) without time zone/' => 'TEXT',
'/timestamp\(\d+\) with time zone/' => 'TEXT',
'/\bjsonb\b/' => 'TEXT',
'/\bjson\b/' => 'TEXT',
'/\buuid\b/' => 'TEXT',
'/double precision/' => 'REAL',
'/numeric\(\d+,\d+\)/' => 'REAL',
'/\bdate\b/' => 'TEXT',
];
private array $castRemovals = [
'::character varying',
'::text',
'::integer',
'::boolean',
'::timestamp without time zone',
'::timestamp with time zone',
'::numeric',
];
public function handle(): int
{
$connection = $this->option('connection');
if (DB::connection($connection)->getDriverName() !== 'pgsql') {
$this->error("Connection '{$connection}' is not PostgreSQL.");
return self::FAILURE;
}
$this->info('Reading schema from PostgreSQL...');
$tables = $this->getTables($connection);
$lastMigration = DB::connection($connection)
->table('migrations')
->orderByDesc('id')
->value('migration');
$output = [];
$output[] = '-- Generated by: php artisan schema:generate-testing';
$output[] = '-- Date: '.now()->format('Y-m-d H:i:s');
$output[] = '-- Last migration: '.($lastMigration ?? 'none');
$output[] = '';
foreach ($tables as $table) {
$columns = $this->getColumns($connection, $table);
$output[] = $this->generateCreateTable($table, $columns);
}
$indexes = $this->getIndexes($connection, $tables);
foreach ($indexes as $index) {
$output[] = $index;
}
$output[] = '';
$output[] = '-- Migration records';
$migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get();
foreach ($migrations as $m) {
$migration = str_replace("'", "''", $m->migration);
$output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});";
}
$path = database_path('schema/testing-schema.sql');
if (! is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, implode("\n", $output)."\n");
$this->info("Schema written to {$path}");
$this->info(count($tables).' tables, '.count($migrations).' migration records.');
return self::SUCCESS;
}
private function getTables(string $connection): array
{
return collect(DB::connection($connection)->select(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
))->pluck('tablename')->toArray();
}
private function getColumns(string $connection, string $table): array
{
return DB::connection($connection)->select(
"SELECT column_name, data_type, character_maximum_length, column_default,
is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ?
ORDER BY ordinal_position",
[$table]
);
}
private function generateCreateTable(string $table, array $columns): string
{
$lines = [];
foreach ($columns as $col) {
$lines[] = ' '.$this->generateColumnDef($table, $col);
}
return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n";
}
private function generateColumnDef(string $table, object $col): string
{
$name = $col->column_name;
$sqliteType = $this->convertType($col);
// Auto-increment primary key for id columns
if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) {
return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL";
}
$parts = ["\"{$name}\"", $sqliteType];
// Default value
$default = $col->column_default;
if ($default !== null && ! str_contains($default, 'nextval')) {
$default = $this->cleanDefault($default);
$parts[] = "DEFAULT {$default}";
}
// NOT NULL
if ($col->is_nullable === 'NO') {
$parts[] = 'NOT NULL';
}
return implode(' ', $parts);
}
private function convertType(object $col): string
{
$pgType = $col->data_type;
return match (true) {
in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER',
$pgType === 'boolean' => 'INTEGER',
in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT',
str_contains($pgType, 'timestamp') => 'TEXT',
in_array($pgType, ['json', 'jsonb']) => 'TEXT',
$pgType === 'uuid' => 'TEXT',
$pgType === 'double precision' => 'REAL',
$pgType === 'numeric' => 'REAL',
$pgType === 'date' => 'TEXT',
default => 'TEXT',
};
}
private function cleanDefault(string $default): string
{
foreach ($this->castRemovals as $cast) {
$default = str_replace($cast, '', $default);
}
// Remove array type casts like ::text[]
$default = preg_replace('/::[\w\s]+(\[\])?/', '', $default);
return $default;
}
private function getIndexes(string $connection, array $tables): array
{
$results = [];
$indexes = DB::connection($connection)->select(
"SELECT indexname, tablename, indexdef FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename, indexname"
);
foreach ($indexes as $idx) {
$def = $idx->indexdef;
// Skip primary key indexes
if (str_contains($def, '_pkey')) {
continue;
}
// Skip PG-specific indexes (GIN, GIST, expression indexes)
if (preg_match('/USING (gin|gist)/i', $def)) {
continue;
}
if (str_contains($def, '->>') || str_contains($def, '::')) {
continue;
}
// Convert to SQLite-compatible CREATE INDEX
$unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : '';
// Extract columns from the index definition
if (preg_match('/\((.+)\)$/', $def, $m)) {
$cols = $m[1];
$results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});";
}
}
return $results;
}
}

View file

@ -40,7 +40,7 @@ class Kernel extends ConsoleKernel
}
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis')->weekly();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
if (isDev()) {
// Instance Jobs

View file

@ -1002,7 +1002,7 @@ class ApplicationsController extends Controller
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@ -1101,7 +1101,6 @@ class ApplicationsController extends Controller
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@ -1297,7 +1296,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@ -1525,7 +1523,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@ -2463,14 +2460,13 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
'description' => 'string|nullable',
'static_image' => 'string',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',

View file

@ -127,6 +127,10 @@ class DeployController extends Controller
if (! $deployment) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
$application = $deployment->application;
if (! $application || data_get($application->team(), 'id') !== $teamId) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
return response()->json($this->removeSensitiveData($deployment));
}

View file

@ -0,0 +1,922 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class ScheduledTasksController extends Controller
{
private function removeSensitiveData($task)
{
$task->makeHidden([
'id',
'team_id',
'application_id',
'service_id',
]);
return serializeApiResponse($task);
}
private function resolveApplication(Request $request, int $teamId): ?Application
{
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
}
private function resolveService(Request $request, int $teamId): ?Service
{
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('view', $resource);
$tasks = $resource->scheduled_tasks->map(function ($task) {
return $this->removeSensitiveData($task);
});
return response()->json($tasks);
}
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'command' => 'required|string',
'frequency' => 'required|string',
'container' => 'string|nullable',
'timeout' => 'integer|min:1',
'enabled' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if (! validate_cron_expression($request->frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
$teamId = getTeamIdFromToken();
$task = new ScheduledTask;
$task->name = $request->name;
$task->command = $request->command;
$task->frequency = $request->frequency;
$task->container = $request->container;
$task->timeout = $request->has('timeout') ? $request->timeout : 300;
$task->enabled = $request->has('enabled') ? $request->enabled : true;
$task->team_id = $teamId;
if ($resource instanceof Application) {
$task->application_id = $resource->id;
} elseif ($resource instanceof Service) {
$task->service_id = $resource->id;
}
$task->save();
return response()->json($this->removeSensitiveData($task), 201);
}
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
if ($request->all() === []) {
return response()->json(['message' => 'At least one field must be provided.'], 422);
}
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
'command' => 'string',
'frequency' => 'string',
'container' => 'string|nullable',
'timeout' => 'integer|min:1',
'enabled' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$task->update($request->only($allowedFields));
return response()->json($this->removeSensitiveData($task), 200);
}
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
if (! $deleted) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
return response()->json(['message' => 'Scheduled task deleted.']);
}
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('view', $resource);
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$executions = $task->executions()->get()->map(function ($execution) {
$execution->makeHidden(['id', 'scheduled_task_id']);
return serializeApiResponse($execution);
});
return response()->json($executions);
}
#[OA\Get(
summary: 'List Tasks',
description: 'List all scheduled tasks for an application.',
path: '/applications/{uuid}/scheduled-tasks',
operationId: 'list-scheduled-tasks-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all scheduled tasks for an application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->listTasks($application);
}
#[OA\Post(
summary: 'Create Task',
description: 'Create a new scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks',
operationId: 'create-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['name', 'command', 'frequency'],
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Scheduled task created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->createTask($request, $application);
}
#[OA\Patch(
summary: 'Update Task',
description: 'Update a scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'update-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->updateTask($request, $application);
}
#[OA\Delete(
summary: 'Delete Task',
description: 'Delete a scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'delete-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->deleteTask($request, $application);
}
#[OA\Get(
summary: 'List Executions',
description: 'List all executions for a scheduled task on an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions',
operationId: 'list-scheduled-task-executions-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all executions for a scheduled task.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->getExecutions($request, $application);
}
#[OA\Get(
summary: 'List Tasks',
description: 'List all scheduled tasks for a service.',
path: '/services/{uuid}/scheduled-tasks',
operationId: 'list-scheduled-tasks-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all scheduled tasks for a service.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->listTasks($service);
}
#[OA\Post(
summary: 'Create Task',
description: 'Create a new scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks',
operationId: 'create-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['name', 'command', 'frequency'],
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Scheduled task created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->createTask($request, $service);
}
#[OA\Patch(
summary: 'Update Task',
description: 'Update a scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'update-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->updateTask($request, $service);
}
#[OA\Delete(
summary: 'Delete Task',
description: 'Delete a scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'delete-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->deleteTask($request, $service);
}
#[OA\Get(
summary: 'List Executions',
description: 'List all executions for a scheduled task on a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions',
operationId: 'list-scheduled-task-executions-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all executions for a scheduled task.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->getExecutions($request, $service);
}
}

View file

@ -290,9 +290,12 @@ class ServersController extends Controller
}
$uuid = $request->get('uuid');
if ($uuid) {
$domains = Application::getDomainsByUuid($uuid);
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return response()->json(serializeApiResponse($domains));
return response()->json(serializeApiResponse($application->fqdns));
}
$projects = Project::where('team_id', $teamId)->get();
$domains = collect();

View file

@ -25,7 +25,7 @@ class CheckForcePasswordReset
}
$force_password_reset = auth()->user()->force_password_reset;
if ($force_password_reset) {
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'two-factor-challenge' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
return $next($request);
}

View file

@ -171,6 +171,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerBuildkitSupported = false;
private bool $dockerSecretsSupported = false;
private bool $skip_build = false;
private Collection|string $build_secrets;
@ -251,7 +253,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
if ($this->application->build_pack === 'dockerfile') {
if (data_get($this->application, 'dockerfile_location')) {
$this->dockerfile_location = $this->application->dockerfile_location;
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
}
}
}
@ -381,13 +383,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function detectBuildKitCapabilities(): void
{
// If build secrets are not enabled, skip detection and use traditional args
if (! $this->application->settings->use_build_secrets) {
$this->dockerBuildkitSupported = false;
return;
}
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
@ -403,53 +398,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled.");
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return;
}
$buildkitEnabled = instant_remote_process(
// Check buildx availability (always installed by Coolify on Docker 24.0+)
$buildxAvailable = instant_remote_process(
["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"],
$serverToCheck
);
if (trim($buildkitEnabled) !== 'available') {
if (trim($buildxAvailable) === 'available') {
$this->dockerBuildkitSupported = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
} else {
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process(
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($buildkitTest) === 'supported') {
$this->dockerBuildkitSupported = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}.");
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit support detected on {$serverName}.");
} else {
$this->dockerBuildkitSupported = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support.");
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit. Build output progress will be limited.");
}
} else {
// Buildx is available, which means BuildKit is available
// Now specifically test for secrets support
}
// If build secrets are enabled and BuildKit is available, verify --secret flag support
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
$secretsTest = instant_remote_process(
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($secretsTest) === 'supported') {
$this->dockerBuildkitSupported = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
$this->dockerSecretsSupported = true;
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
} else {
$this->dockerBuildkitSupported = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported.");
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
}
}
} catch (\Exception $e) {
$this->dockerBuildkitSupported = false;
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.');
}
}
@ -571,7 +568,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function deploy_docker_compose_buildpack()
{
if (data_get($this->application, 'docker_compose_location')) {
$this->docker_compose_location = $this->application->docker_compose_location;
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
}
if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
@ -632,7 +629,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// For raw compose, we cannot automatically add secrets configuration
// User must define it manually in their docker-compose file
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
}
} else {
@ -653,7 +650,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Add build secrets to compose file if enabled and BuildKit is supported
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$composeFile = $this->add_build_secrets_to_compose($composeFile);
}
@ -689,8 +686,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Inject build arguments after build subcommand if not using build secrets
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
// Escape single quotes for bash -c context used by executeInDocker
$build_args_string = str_replace("'", "'\\''", $build_args_string);
// Inject build args right after 'build' subcommand (not at the end)
$original_command = $build_command;
@ -702,9 +697,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
);
try {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
);
} catch (\RuntimeException $e) {
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}");
}
throw $e;
}
} else {
$command = "{$this->coolify_variables} docker compose";
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
@ -721,8 +724,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
// Escape single quotes for bash -c context used by executeInDocker
$build_args_string = str_replace("'", "'\\''", $build_args_string);
$command .= " {$build_args_string}";
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
}
@ -768,9 +769,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
);
$this->write_deployment_configurations();
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
);
try {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
);
} catch (\RuntimeException $e) {
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}");
}
throw $e;
}
} else {
$this->write_deployment_configurations();
$this->docker_compose_location = '/docker-compose.yaml';
@ -831,7 +841,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->server = $this->build_server;
}
if (data_get($this->application, 'dockerfile_location')) {
$this->dockerfile_location = $this->application->dockerfile_location;
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
}
$this->prepare_builder_image();
$this->check_git_if_build_needed();
@ -1800,7 +1810,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$counter = 1;
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
$healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
$this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
}
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
$sleeptime = 0;
@ -2758,29 +2769,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function generate_healthcheck_commands()
{
// Handle CMD type healthcheck
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
$this->full_healthcheck_url = $this->application->health_check_command;
return $this->application->health_check_command;
}
// HTTP type healthcheck (default)
if (! $this->application->health_check_port) {
$health_check_port = $this->application->ports_exposes_array[0];
$health_check_port = (int) $this->application->ports_exposes_array[0];
} else {
$health_check_port = $this->application->health_check_port;
$health_check_port = (int) $this->application->health_check_port;
}
if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
$health_check_port = 80;
}
if ($this->application->health_check_path) {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path}";
$generated_healthchecks_commands = [
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || exit 1",
];
$method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
$path = $this->application->health_check_path
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
: null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
$method = escapeshellarg($method);
if ($path) {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
} else {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/";
$generated_healthchecks_commands = [
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || exit 1",
];
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
}
$generated_healthchecks_commands = [
"curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
];
return implode(' ', $generated_healthchecks_commands);
}
private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string
{
if (preg_match($pattern, $value)) {
return $value;
}
return $default;
}
private function pull_latest_image($image)
{
$this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry.");
@ -2817,7 +2854,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
if ($this->dockerBuildkitSupported) {
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
} else {
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@ -2857,21 +2898,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function build_image()
{
// Add Coolify related variables to the build args/secrets
if ($this->dockerBuildkitSupported) {
// Coolify variables are already included in the secrets from generate_build_env_variables
// build_secrets is already a string at this point
} else {
if (! $this->dockerBuildkitSupported) {
// Traditional build args approach - generate COOLIFY_ variables locally
// Generate COOLIFY_ variables locally for build args
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
$this->build_args->push("--build-arg '{$key}'");
});
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
? $this->build_args->implode(' ')
: (string) $this->build_args;
}
// Always convert build_args Collection to string for command interpolation
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
? $this->build_args->implode(' ')
: (string) $this->build_args;
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if ($this->disableBuildCache) {
$this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.');
@ -2899,7 +2938,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@ -2907,9 +2946,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
ray($build_command);
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@ -2919,18 +2957,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
}
@ -2952,7 +2988,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@ -2963,19 +2999,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -3010,7 +3044,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
if ($this->dockerBuildkitSupported) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@ -3035,7 +3073,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
// Pure Dockerfile based deployment
if ($this->application->dockerfile) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@ -3044,12 +3082,19 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
$build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
}
} else {
// Traditional build with args
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args (no --progress for legacy builder compatibility)
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -3079,18 +3124,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@ -3100,18 +3143,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -3132,7 +3173,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
// Use BuildKit with secrets
@ -3144,19 +3185,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -3332,7 +3371,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->analyzeBuildTimeVariables($variables);
}
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
$this->generate_build_secrets($variables);
$this->build_args = '';
} else {
@ -3819,7 +3858,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist.");
}
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}";
$this->modify_dockerfile_for_secrets($fullDockerfilePath);
$this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets.");
@ -3879,6 +3918,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return $composeFile;
}
private function validatePathField(string $value, string $fieldName): string
{
if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
}
if (str_contains($value, '..')) {
throw new \RuntimeException("Invalid {$fieldName}: path traversal detected.");
}
return $value;
}
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {

View file

@ -207,6 +207,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$serviceId = $labels->get('coolify.serviceId');
$subType = $labels->get('coolify.service.subType');
$subId = $labels->get('coolify.service.subId');
if (empty($subId)) {
continue;
}
if ($subType === 'application') {
$this->foundServiceApplicationIds->push($subId);
// Store container status for aggregation

View file

@ -15,7 +15,9 @@ use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class ScheduledJobManager implements ShouldQueue
{
@ -27,6 +29,10 @@ class ScheduledJobManager implements ShouldQueue
*/
private ?Carbon $executionTime = null;
private int $dispatchedCount = 0;
private int $skippedCount = 0;
/**
* Create a new job instance.
*/
@ -50,6 +56,11 @@ class ScheduledJobManager implements ShouldQueue
*/
public function middleware(): array
{
// Self-healing: clear any stale lock before WithoutOverlapping tries to acquire it.
// Stale locks (TTL = -1) can occur during upgrades, Redis restarts, or edge cases.
// @see https://github.com/coollabsio/coolify/issues/8327
self::clearStaleLockIfPresent();
return [
(new WithoutOverlapping('scheduled-job-manager'))
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
@ -57,10 +68,44 @@ class ScheduledJobManager implements ShouldQueue
];
}
/**
* Clear a stale WithoutOverlapping lock if it has no TTL (TTL = -1).
*
* This provides continuous self-healing since it runs every time the job is dispatched.
* Stale locks permanently block all scheduled job executions with no user-visible error.
*/
private static function clearStaleLockIfPresent(): void
{
try {
$cachePrefix = config('cache.prefix', '');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.self::class.':scheduled-job-manager';
$ttl = Redis::connection('default')->ttl($lockKey);
if ($ttl === -1) {
Redis::connection('default')->del($lockKey);
Log::channel('scheduled')->warning('Cleared stale ScheduledJobManager lock', [
'lock_key' => $lockKey,
]);
}
} catch (\Throwable $e) {
// Never let lock cleanup failure prevent the job from running
Log::channel('scheduled-errors')->error('Failed to check/clear stale lock', [
'error' => $e->getMessage(),
]);
}
}
public function handle(): void
{
// Freeze the execution time at the start of the job
$this->executionTime = Carbon::now();
$this->dispatchedCount = 0;
$this->skippedCount = 0;
Log::channel('scheduled')->info('ScheduledJobManager started', [
'execution_time' => $this->executionTime->toIso8601String(),
]);
// Process backups - don't let failures stop task processing
try {
@ -91,6 +136,20 @@ class ScheduledJobManager implements ShouldQueue
'trace' => $e->getTraceAsString(),
]);
}
Log::channel('scheduled')->info('ScheduledJobManager completed', [
'execution_time' => $this->executionTime->toIso8601String(),
'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()),
'dispatched' => $this->dispatchedCount,
'skipped' => $this->skippedCount,
]);
// Write heartbeat so the UI can detect when the scheduler has stopped
try {
Cache::put('scheduled-job-manager:heartbeat', now()->toIso8601String(), 300);
} catch (\Throwable) {
// Non-critical; don't let heartbeat failure affect the job
}
}
private function processScheduledBackups(): void
@ -101,8 +160,16 @@ class ScheduledJobManager implements ShouldQueue
foreach ($backups as $backup) {
try {
// Apply the same filtering logic as the original
if (! $this->shouldProcessBackup($backup)) {
$skipReason = $this->getBackupSkipReason($backup);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('backup', $skipReason, [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
]);
continue;
}
@ -120,6 +187,14 @@ class ScheduledJobManager implements ShouldQueue
if ($this->shouldRunNow($frequency, $serverTimezone)) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
'server_id' => $server->id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [
@ -138,7 +213,15 @@ class ScheduledJobManager implements ShouldQueue
foreach ($tasks as $task) {
try {
if (! $this->shouldProcessTask($task)) {
$skipReason = $this->getTaskSkipReason($task);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('task', $skipReason, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $task->server()?->team_id,
]);
continue;
}
@ -156,6 +239,13 @@ class ScheduledJobManager implements ShouldQueue
if ($this->shouldRunNow($frequency, $serverTimezone)) {
ScheduledTaskJob::dispatch($task);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [
@ -166,33 +256,33 @@ class ScheduledJobManager implements ShouldQueue
}
}
private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool
private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string
{
if (blank(data_get($backup, 'database'))) {
$backup->delete();
return false;
return 'database_deleted';
}
$server = $backup->server();
if (blank($server)) {
$backup->delete();
return false;
return 'server_deleted';
}
if ($server->isFunctional() === false) {
return false;
return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return false;
return 'subscription_unpaid';
}
return true;
return null;
}
private function shouldProcessTask(ScheduledTask $task): bool
private function getTaskSkipReason(ScheduledTask $task): ?string
{
$service = $task->service;
$application = $task->application;
@ -201,32 +291,32 @@ class ScheduledJobManager implements ShouldQueue
if (blank($server)) {
$task->delete();
return false;
return 'server_deleted';
}
if ($server->isFunctional() === false) {
return false;
return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return false;
return 'subscription_unpaid';
}
if (! $service && ! $application) {
$task->delete();
return false;
return 'resource_deleted';
}
if ($application && str($application->status)->contains('running') === false) {
return false;
return 'application_not_running';
}
if ($service && str($service->status)->contains('running') === false) {
return false;
return 'service_not_running';
}
return true;
return null;
}
private function shouldRunNow(string $frequency, string $timezone): bool
@ -248,7 +338,15 @@ class ScheduledJobManager implements ShouldQueue
foreach ($servers as $server) {
try {
if (! $this->shouldProcessDockerCleanup($server)) {
$skipReason = $this->getDockerCleanupSkipReason($server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('docker_cleanup', $skipReason, [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
continue;
}
@ -270,6 +368,12 @@ class ScheduledJobManager implements ShouldQueue
$server->settings->delete_unused_volumes,
$server->settings->delete_unused_networks
);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Docker cleanup dispatched', [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
@ -296,19 +400,28 @@ class ScheduledJobManager implements ShouldQueue
return $query->get();
}
private function shouldProcessDockerCleanup(Server $server): bool
private function getDockerCleanupSkipReason(Server $server): ?string
{
if (! $server->isFunctional()) {
return false;
return 'server_not_functional';
}
// In cloud, check subscription status (except team 0)
if (isCloud() && $server->team_id !== 0) {
if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) {
return false;
return 'subscription_unpaid';
}
}
return true;
return null;
}
private function logSkip(string $type, string $reason, array $context = []): void
{
Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([
'type' => $type,
'skip_reason' => $reason,
'execution_time' => $this->executionTime?->toIso8601String(),
], $context));
}
}

View file

@ -15,6 +15,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
{
@ -33,6 +34,19 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public Server $server) {}
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
public function handle()
{
try {

View file

@ -101,12 +101,31 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
'is_usable' => false,
]);
throw $e;
return;
}
}
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerConnectionCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
private function checkHetznerStatus(): void
{
$status = null;
try {
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);

View file

@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Horizon\Contracts\Silenced;
@ -28,6 +29,19 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public function __construct(public Server $server, public int|string|null $percentage = null) {}
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerStorageCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
public function handle()
{
try {

View file

@ -1495,6 +1495,7 @@ class GlobalSearch extends Component
'type' => 'one-click-service-'.$serviceKey,
'category' => 'Services',
'resourceType' => 'service',
'logo' => data_get($service, 'logo'),
]);
}

View file

@ -73,7 +73,7 @@ class General extends Component
#[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
public ?string $dockerfileLocation = null;
#[Validate(['string', 'nullable'])]
@ -85,7 +85,7 @@ class General extends Component
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
public ?string $dockerComposeLocation = null;
#[Validate(['string', 'nullable'])]

View file

@ -163,10 +163,12 @@ class GithubPrivateRepository extends Component
'selected_repository_owner' => $this->selected_repository_owner,
'selected_repository_repo' => $this->selected_repository_repo,
'selected_branch_name' => $this->selected_branch_name,
'docker_compose_location' => $this->docker_compose_location,
], [
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
]);
if ($validator->fails()) {

View file

@ -64,6 +64,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
protected function rules()
@ -75,6 +76,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
}

View file

@ -70,7 +70,7 @@ class PublicGitRepository extends Component
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
'docker_compose_location' => 'nullable|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
protected function rules()
@ -82,7 +82,7 @@ class PublicGitRepository extends Component
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
'docker_compose_location' => 'nullable|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
'git_branch' => ['required', 'string', new ValidGitBranch],
];
}

View file

@ -16,19 +16,25 @@ class HealthChecks extends Component
#[Validate(['boolean'])]
public bool $healthCheckEnabled = false;
#[Validate(['string'])]
#[Validate(['string', 'in:http,cmd'])]
public string $healthCheckType = 'http';
#[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])]
public ?string $healthCheckCommand = null;
#[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
public string $healthCheckMethod;
#[Validate(['string'])]
#[Validate(['required', 'string', 'in:http,https'])]
public string $healthCheckScheme;
#[Validate(['string'])]
#[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])]
public string $healthCheckHost;
#[Validate(['nullable', 'string'])]
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
public ?string $healthCheckPort = null;
#[Validate(['string'])]
#[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
public string $healthCheckPath;
#[Validate(['integer'])]
@ -54,12 +60,14 @@ class HealthChecks extends Component
protected $rules = [
'healthCheckEnabled' => 'boolean',
'healthCheckPath' => 'string',
'healthCheckPort' => 'nullable|string',
'healthCheckHost' => 'string',
'healthCheckMethod' => 'string',
'healthCheckType' => 'string|in:http,cmd',
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
'healthCheckReturnCode' => 'integer',
'healthCheckScheme' => 'string',
'healthCheckScheme' => 'required|string|in:http,https',
'healthCheckResponseText' => 'nullable|string',
'healthCheckInterval' => 'integer|min:1',
'healthCheckTimeout' => 'integer|min:1',
@ -81,6 +89,8 @@ class HealthChecks extends Component
// Sync to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_type = $this->healthCheckType;
$this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@ -98,6 +108,8 @@ class HealthChecks extends Component
} else {
// Sync from model
$this->healthCheckEnabled = $this->resource->health_check_enabled;
$this->healthCheckType = $this->resource->health_check_type ?? 'http';
$this->healthCheckCommand = $this->resource->health_check_command;
$this->healthCheckMethod = $this->resource->health_check_method;
$this->healthCheckScheme = $this->resource->health_check_scheme;
$this->healthCheckHost = $this->resource->health_check_host;
@ -116,9 +128,12 @@ class HealthChecks extends Component
public function instantSave()
{
$this->authorize('update', $this->resource);
$this->validate();
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_type = $this->healthCheckType;
$this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@ -143,6 +158,8 @@ class HealthChecks extends Component
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_type = $this->healthCheckType;
$this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@ -171,6 +188,8 @@ class HealthChecks extends Component
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_type = $this->healthCheckType;
$this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;

View file

@ -49,9 +49,10 @@ class ResourceOperations extends Component
{
$this->authorize('update', $this->resource);
$new_destination = StandaloneDocker::find($destination_id);
$teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
$new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
if (! $new_destination) {
$new_destination = SwarmDocker::find($destination_id);
$new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
}
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
@ -352,7 +353,7 @@ class ResourceOperations extends Component
{
try {
$this->authorize('update', $this->resource);
$new_environment = Environment::findOrFail($environment_id);
$new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id);
$this->resource->update([
'environment_id' => $environment_id,
]);

View file

@ -60,10 +60,16 @@ class Show extends Component
throw new \Exception('Certificate content cannot be empty.');
}
if (! openssl_x509_read($this->certificateContent)) {
$parsedCert = openssl_x509_read($this->certificateContent);
if (! $parsedCert) {
throw new \Exception('Invalid certificate format.');
}
if (! openssl_x509_export($parsedCert, $cleanedCertificate)) {
throw new \Exception('Failed to process certificate.');
}
$this->certificateContent = $cleanedCertificate;
if ($this->caCertificate) {
$this->caCertificate->ssl_certificate = $this->certificateContent;
$this->caCertificate->save();
@ -114,12 +120,14 @@ class Show extends Component
{
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($this->certificateContent);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);

View file

@ -3,8 +3,13 @@
namespace App\Livewire\Server;
use App\Jobs\DockerCleanupJob;
use App\Models\DockerCleanupExecution;
use App\Models\Server;
use Cron\CronExpression;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -34,6 +39,53 @@ class DockerCleanup extends Component
#[Validate('boolean')]
public bool $disableApplicationImageRetention = false;
#[Computed]
public function isCleanupStale(): bool
{
try {
$lastExecution = DockerCleanupExecution::where('server_id', $this->server->id)
->orderBy('created_at', 'desc')
->first();
if (! $lastExecution) {
return false;
}
$frequency = $this->server->settings->docker_cleanup_frequency ?? '0 0 * * *';
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$cron = new CronExpression($frequency);
$now = Carbon::now();
$nextRun = Carbon::parse($cron->getNextRunDate($now));
$afterThat = Carbon::parse($cron->getNextRunDate($nextRun));
$intervalMinutes = $nextRun->diffInMinutes($afterThat);
$threshold = max($intervalMinutes * 2, 10);
return Carbon::parse($lastExecution->created_at)->diffInMinutes($now) > $threshold;
} catch (\Throwable) {
return false;
}
}
#[Computed]
public function lastExecutionTime(): ?string
{
return DockerCleanupExecution::where('server_id', $this->server->id)
->orderBy('created_at', 'desc')
->first()
?->created_at
?->diffForHumans();
}
#[Computed]
public function isSchedulerHealthy(): bool
{
return Cache::get('scheduled-job-manager:heartbeat') !== null;
}
public function mount(string $server_uuid)
{
try {

View file

@ -0,0 +1,211 @@
<?php
namespace App\Livewire\Settings;
use App\Models\DockerCleanupExecution;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTaskExecution;
use App\Services\SchedulerLogParser;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Component;
class ScheduledJobs extends Component
{
public string $filterType = 'all';
public string $filterDate = 'last_24h';
protected Collection $executions;
protected Collection $skipLogs;
protected Collection $managerRuns;
public function boot(): void
{
$this->executions = collect();
$this->skipLogs = collect();
$this->managerRuns = collect();
}
public function mount(): void
{
if (! isInstanceAdmin()) {
redirect()->route('dashboard');
return;
}
$this->loadData();
}
public function updatedFilterType(): void
{
$this->loadData();
}
public function updatedFilterDate(): void
{
$this->loadData();
}
public function refresh(): void
{
$this->loadData();
}
public function render()
{
return view('livewire.settings.scheduled-jobs', [
'executions' => $this->executions,
'skipLogs' => $this->skipLogs,
'managerRuns' => $this->managerRuns,
]);
}
private function loadData(?int $teamId = null): void
{
$this->executions = $this->getExecutions($teamId);
$parser = new SchedulerLogParser;
$this->skipLogs = $parser->getRecentSkips(50, $teamId);
$this->managerRuns = $parser->getRecentRuns(30, $teamId);
}
private function getExecutions(?int $teamId = null): Collection
{
$dateFrom = $this->getDateFrom();
$backups = collect();
$tasks = collect();
$cleanups = collect();
if ($this->filterType === 'all' || $this->filterType === 'backup') {
$backups = $this->getBackupExecutions($dateFrom, $teamId);
}
if ($this->filterType === 'all' || $this->filterType === 'task') {
$tasks = $this->getTaskExecutions($dateFrom, $teamId);
}
if ($this->filterType === 'all' || $this->filterType === 'cleanup') {
$cleanups = $this->getCleanupExecutions($dateFrom, $teamId);
}
return $backups->concat($tasks)->concat($cleanups)
->sortByDesc('created_at')
->values()
->take(100);
}
private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection
{
$query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team'])
->where('status', 'failed')
->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId))
->orderBy('created_at', 'desc')
->limit(100)
->get();
return $query->map(function ($execution) {
$backup = $execution->scheduledDatabaseBackup;
$database = $backup?->database;
$server = $backup?->server();
return [
'id' => $execution->id,
'type' => 'backup',
'status' => $execution->status ?? 'unknown',
'resource_name' => $database?->name ?? 'Deleted database',
'resource_type' => $database ? class_basename($database) : null,
'server_name' => $server?->name ?? 'Unknown',
'server_id' => $server?->id,
'team_id' => $backup?->team_id,
'created_at' => $execution->created_at,
'finished_at' => $execution->updated_at,
'message' => $execution->message,
'size' => $execution->size ?? null,
];
});
}
private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection
{
$query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service'])
->where('status', 'failed')
->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
->when($teamId, function ($q) use ($teamId) {
$q->where(function ($sub) use ($teamId) {
$sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId)
->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId);
});
})
->orderBy('created_at', 'desc')
->limit(100)
->get();
return $query->map(function ($execution) {
$task = $execution->scheduledTask;
$resource = $task?->application ?? $task?->service;
$server = $task?->server();
$teamId = $server?->team_id;
return [
'id' => $execution->id,
'type' => 'task',
'status' => $execution->status ?? 'unknown',
'resource_name' => $task?->name ?? 'Deleted task',
'resource_type' => $resource ? class_basename($resource) : null,
'server_name' => $server?->name ?? 'Unknown',
'server_id' => $server?->id,
'team_id' => $teamId,
'created_at' => $execution->created_at,
'finished_at' => $execution->finished_at,
'message' => $execution->message,
'size' => null,
];
});
}
private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection
{
$query = DockerCleanupExecution::with(['server'])
->where('status', 'failed')
->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId))
->orderBy('created_at', 'desc')
->limit(100)
->get();
return $query->map(function ($execution) {
$server = $execution->server;
return [
'id' => $execution->id,
'type' => 'cleanup',
'status' => $execution->status ?? 'unknown',
'resource_name' => $server?->name ?? 'Deleted server',
'resource_type' => 'Server',
'server_name' => $server?->name ?? 'Unknown',
'server_id' => $server?->id,
'team_id' => $server?->team_id,
'created_at' => $execution->created_at,
'finished_at' => $execution->finished_at ?? $execution->updated_at,
'message' => $execution->message,
'size' => null,
];
});
}
private function getDateFrom(): ?Carbon
{
return match ($this->filterDate) {
'last_24h' => now()->subDay(),
'last_7d' => now()->subWeek(),
'last_30d' => now()->subMonth(),
default => null,
};
}
}

View file

@ -61,6 +61,8 @@ use Visus\Cuid2\Cuid2;
'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'],
'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'],
'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']],
'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'],
@ -990,7 +992,7 @@ class Application extends BaseModel
if (isDev() && data_get($this, 'private_key_id') === 0) {
return 'deploy_key';
}
if (data_get($this, 'private_key_id')) {
if (! is_null(data_get($this, 'private_key_id'))) {
return 'deploy_key';
} elseif (data_get($this, 'source')) {
return 'source';
@ -1959,16 +1961,6 @@ class Application extends BaseModel
}
}
public static function getDomainsByUuid(string $uuid): array
{
$application = self::where('uuid', $uuid)->first();
if ($application) {
return $application->fqdns;
}
return [];
}
public function getLimits(): array
{

View file

@ -5,13 +5,35 @@ namespace App\Models;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Scheduled Task model',
type: 'object',
properties: [
'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'],
'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'],
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'],
'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'],
'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'],
],
)]
class ScheduledTask extends BaseModel
{
use HasSafeStringAttribute;
protected $guarded = [];
public static function ownedByCurrentTeamAPI(int $teamId)
{
return static::where('team_id', $teamId)->orderBy('created_at', 'desc');
}
protected function casts(): array
{
return [

View file

@ -3,7 +3,23 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Scheduled Task Execution model',
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'],
'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'],
'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'],
'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'],
'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'],
'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'],
'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'],
'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'],
'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'],
],
)]
class ScheduledTaskExecution extends BaseModel
{
protected $guarded = [];

View file

@ -1452,12 +1452,14 @@ $schema://$host {
$certificateContent = $caCertificate->ssl_certificate;
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($certificateContent);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$certificateContent}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);

View file

@ -88,7 +88,7 @@ class ServiceApplication extends BaseModel
public function team()
{
return data_get($this, 'environment.project.team');
return data_get($this, 'service.environment.project.team');
}
public function workdir()

View file

@ -124,7 +124,7 @@ class ServiceDatabase extends BaseModel
public function team()
{
return data_get($this, 'environment.project.team');
return data_get($this, 'service.environment.project.team');
}
public function workdir()

View file

@ -191,7 +191,8 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
$this->getNotificationSettings('discord')?->isEnabled() ||
$this->getNotificationSettings('slack')?->isEnabled() ||
$this->getNotificationSettings('telegram')?->isEnabled() ||
$this->getNotificationSettings('pushover')?->isEnabled();
$this->getNotificationSettings('pushover')?->isEnabled() ||
$this->getNotificationSettings('webhook')?->isEnabled();
}
public function subscriptionEnded()

View file

@ -37,8 +37,7 @@ class StandaloneDockerPolicy
*/
public function update(User $user, StandaloneDocker $standaloneDocker): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
return true;
return $user->teams->contains('id', $standaloneDocker->server->team_id);
}
/**
@ -46,8 +45,7 @@ class StandaloneDockerPolicy
*/
public function delete(User $user, StandaloneDocker $standaloneDocker): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
return true;
return $user->teams->contains('id', $standaloneDocker->server->team_id);
}
/**
@ -55,8 +53,7 @@ class StandaloneDockerPolicy
*/
public function restore(User $user, StandaloneDocker $standaloneDocker): bool
{
// return false;
return true;
return false;
}
/**
@ -64,7 +61,6 @@ class StandaloneDockerPolicy
*/
public function forceDelete(User $user, StandaloneDocker $standaloneDocker): bool
{
// return false;
return true;
return false;
}
}

View file

@ -37,8 +37,7 @@ class SwarmDockerPolicy
*/
public function update(User $user, SwarmDocker $swarmDocker): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
return true;
return $user->teams->contains('id', $swarmDocker->server->team_id);
}
/**
@ -46,8 +45,7 @@ class SwarmDockerPolicy
*/
public function delete(User $user, SwarmDocker $swarmDocker): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
return true;
return $user->teams->contains('id', $swarmDocker->server->team_id);
}
/**
@ -55,8 +53,7 @@ class SwarmDockerPolicy
*/
public function restore(User $user, SwarmDocker $swarmDocker): bool
{
// return false;
return true;
return false;
}
/**
@ -64,7 +61,6 @@ class SwarmDockerPolicy
*/
public function forceDelete(User $user, SwarmDocker $swarmDocker): bool
{
// return false;
return true;
return false;
}
}

View file

@ -0,0 +1,188 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
class SchedulerLogParser
{
/**
* Get recent skip events from the scheduled log files.
*
* @return Collection<int, array{timestamp: string, type: string, reason: string, team_id: ?int, context: array}>
*/
public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection
{
$logFiles = $this->getLogFiles();
$skips = collect();
foreach ($logFiles as $logFile) {
$lines = $this->readLastLines($logFile, 2000);
foreach ($lines as $line) {
$entry = $this->parseLogLine($line);
if ($entry === null || ! isset($entry['context']['skip_reason'])) {
continue;
}
if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) {
continue;
}
$skips->push([
'timestamp' => $entry['timestamp'],
'type' => $entry['context']['type'] ?? 'unknown',
'reason' => $entry['context']['skip_reason'],
'team_id' => $entry['context']['team_id'] ?? null,
'context' => $entry['context'],
]);
}
}
return $skips->sortByDesc('timestamp')->values()->take($limit);
}
/**
* Get recent manager execution logs (start/complete events).
*
* @return Collection<int, array{timestamp: string, message: string, duration_ms: ?int, dispatched: ?int, skipped: ?int}>
*/
public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection
{
$logFiles = $this->getLogFiles();
$runs = collect();
foreach ($logFiles as $logFile) {
$lines = $this->readLastLines($logFile, 2000);
foreach ($lines as $line) {
$entry = $this->parseLogLine($line);
if ($entry === null) {
continue;
}
if (! str_contains($entry['message'], 'ScheduledJobManager')) {
continue;
}
$runs->push([
'timestamp' => $entry['timestamp'],
'message' => $entry['message'],
'duration_ms' => $entry['context']['duration_ms'] ?? null,
'dispatched' => $entry['context']['dispatched'] ?? null,
'skipped' => $entry['context']['skipped'] ?? null,
]);
}
}
return $runs->sortByDesc('timestamp')->values()->take($limit);
}
private function getLogFiles(): array
{
$logDir = storage_path('logs');
if (! File::isDirectory($logDir)) {
return [];
}
$files = File::glob($logDir.'/scheduled-*.log');
// Sort by modification time, newest first
usort($files, fn ($a, $b) => filemtime($b) - filemtime($a));
// Only check last 3 days of logs
return array_slice($files, 0, 3);
}
/**
* @return array{timestamp: string, level: string, message: string, context: array}|null
*/
private function parseLogLine(string $line): ?array
{
// Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"}
if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) {
return null;
}
$timestamp = $matches[1];
$level = $matches[2];
$rest = $matches[3];
// Extract JSON context if present
$context = [];
if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) {
$message = $contextMatches[1];
$decoded = json_decode($contextMatches[2], true);
if (is_array($decoded)) {
$context = $decoded;
}
} else {
$message = $rest;
}
return [
'timestamp' => $timestamp,
'level' => $level,
'message' => $message,
'context' => $context,
];
}
/**
* Efficiently read the last N lines of a file.
*
* @return string[]
*/
private function readLastLines(string $filePath, int $lines): array
{
if (! File::exists($filePath)) {
return [];
}
$fileSize = File::size($filePath);
if ($fileSize === 0) {
return [];
}
// For small files, read the whole thing
if ($fileSize < 1024 * 1024) {
$content = File::get($filePath);
return array_filter(explode("\n", $content), fn ($line) => $line !== '');
}
// For large files, read from the end
$handle = fopen($filePath, 'r');
if ($handle === false) {
return [];
}
$result = [];
$chunkSize = 8192;
$buffer = '';
$position = $fileSize;
while ($position > 0 && count($result) < $lines) {
$readSize = min($chunkSize, $position);
$position -= $readSize;
fseek($handle, $position);
$buffer = fread($handle, $readSize).$buffer;
$bufferLines = explode("\n", $buffer);
$buffer = array_shift($bufferLines);
$result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result);
}
if ($buffer !== '' && count($result) < $lines) {
array_unshift($result, $buffer);
}
fclose($handle);
return array_slice($result, -$lines);
}
}

View file

@ -95,9 +95,6 @@ trait SshRetryable
if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) {
$delay = $this->calculateRetryDelay($attempt);
// Track SSH retry event in Sentry
$this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context);
// Add deployment log if available (for ExecuteRemoteCommand trait)
if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) {
$this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage);
@ -133,42 +130,4 @@ trait SshRetryable
return null;
}
/**
* Track SSH retry event in Sentry
*/
protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void
{
// Only track in production/cloud instances
if (isDev() || ! config('constants.sentry.sentry_dsn')) {
return;
}
try {
app('sentry')->captureMessage(
'SSH connection retry triggered',
\Sentry\Severity::warning(),
[
'extra' => [
'attempt' => $attempt,
'max_retries' => $maxRetries,
'delay_seconds' => $delay,
'error_message' => $errorMessage,
'context' => $context,
'retryable_error' => true,
],
'tags' => [
'component' => 'ssh_retry',
'error_type' => 'connection_retry',
],
]
);
} catch (\Throwable $e) {
// Don't let Sentry tracking errors break the SSH retry flow
Log::warning('Failed to track SSH retry event in Sentry', [
'error' => $e->getMessage(),
'original_attempt' => $attempt,
]);
}
}
}

View file

@ -104,12 +104,14 @@ function sharedDataApplications()
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'health_check_enabled' => 'boolean',
'health_check_path' => 'string',
'health_check_port' => 'string|nullable',
'health_check_host' => 'string',
'health_check_method' => 'string',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'health_check_port' => 'integer|nullable|min:1|max:65535',
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
'health_check_return_code' => 'numeric',
'health_check_scheme' => 'string',
'health_check_scheme' => 'string|in:http,https',
'health_check_response_text' => 'string|nullable',
'health_check_interval' => 'numeric',
'health_check_timeout' => 'numeric',
@ -132,8 +134,8 @@ function sharedDataApplications()
'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable',
'dockerfile_location' => 'string|nullable',
'docker_compose_location' => 'string',
'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => 'string|nullable',

View file

@ -191,6 +191,10 @@ function clone_application(Application $source, $destination, array $overrides =
$uuid = $overrides['uuid'] ?? (string) new Cuid2;
$server = $destination->server;
if ($server->team_id !== currentTeam()->id) {
throw new \RuntimeException('Destination does not belong to the current team.');
}
// Prepare name and URL
$name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid;
$applicationSettings = $source->settings;

View file

@ -139,8 +139,9 @@ function checkMinimumDockerEngineVersion($dockerVersion)
}
function executeInDocker(string $containerId, string $command)
{
return "docker exec {$containerId} bash -c '{$command}'";
// return "docker exec {$this->deployment_uuid} bash -c '{$command} |& tee -a /proc/1/fd/1; [ \$PIPESTATUS -eq 0 ] || exit \$PIPESTATUS'";
$escapedCommand = str_replace("'", "'\\''", $command);
return "docker exec {$containerId} bash -c '{$escapedCommand}'";
}
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)

View file

@ -1233,7 +1233,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@ -1246,7 +1246,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
network: $network,
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@ -1260,7 +1260,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@ -1271,7 +1271,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
network: $network,
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),

View file

@ -167,6 +167,10 @@ function currentTeam()
function showBoarding(): bool
{
if (isDev()) {
return false;
}
if (Auth::user()?->isMember()) {
return false;
}

View file

@ -77,6 +77,7 @@ function allowedPathsForUnsubscribedAccounts()
'login',
'logout',
'force-password-reset',
'two-factor-challenge',
'livewire/update',
'admin',
];
@ -95,6 +96,7 @@ function allowedPathsForInvalidAccounts()
'logout',
'verify',
'force-password-reset',
'two-factor-challenge',
'livewire/update',
];
}

View file

@ -69,6 +69,7 @@
"mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.8.3",
"pestphp/pest": "^4.3.2",
"pestphp/pest-plugin-browser": "^4.2",
"phpstan/phpstan": "^2.1.38",
"rector/rector": "^2.3.5",
"serversideup/spin": "^3.1.1",

1565
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.463',
'version' => '4.0.0-beta.464',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -54,18 +54,10 @@ return [
],
'testing' => [
'driver' => 'pgsql',
'url' => env('DATABASE_TEST_URL'),
'host' => env('DB_TEST_HOST', 'postgres'),
'port' => env('DB_TEST_PORT', '5432'),
'database' => env('DB_TEST_DATABASE', 'coolify_test'),
'username' => env('DB_TEST_USERNAME', 'coolify'),
'password' => env('DB_TEST_PASSWORD', 'password'),
'charset' => 'utf8',
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'foreign_key_constraints' => true,
],
],

View file

@ -184,13 +184,13 @@ return [
'connection' => 'redis',
'balance' => env('HORIZON_BALANCE', 'false'),
'queue' => env('HORIZON_QUEUES', 'high,default'),
'maxTime' => 3600,
'maxTime' => env('HORIZON_MAX_TIME', 0),
'maxJobs' => 400,
'memory' => 128,
'tries' => 1,
'nice' => 0,
'sleep' => 3,
'timeout' => 3600,
'timeout' => env('HORIZON_TIMEOUT', 36000),
],
],

View file

@ -0,0 +1,21 @@
<?php
namespace Database\Factories;
use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;
class ScheduledTaskFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->word(),
'command' => 'echo hello',
'frequency' => '* * * * *',
'timeout' => 300,
'enabled' => true,
'team_id' => Team::factory(),
];
}
}

View file

@ -8,6 +8,10 @@ class AddIndexToActivityLog extends Migration
{
public function up()
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
try {
DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE jsonb USING properties::jsonb');
DB::statement('CREATE INDEX idx_activity_type_uuid ON activity_log USING GIN (properties jsonb_path_ops)');
@ -18,6 +22,10 @@ class AddIndexToActivityLog extends Migration
public function down()
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
try {
DB::statement('DROP INDEX IF EXISTS idx_activity_type_uuid');
DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE json USING properties::json');

View file

@ -22,6 +22,10 @@ return new class extends Migration
public function up(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
foreach ($this->indexes as [$table, $columns, $indexName]) {
if (! $this->indexExists($indexName)) {
$columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns));
@ -32,6 +36,10 @@ return new class extends Migration
public function down(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
foreach ($this->indexes as [, , $indexName]) {
DB::statement("DROP INDEX IF EXISTS \"{$indexName}\"");
}

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumn('applications', 'health_check_type')) {
Schema::table('applications', function (Blueprint $table) {
$table->text('health_check_type')->default('http')->after('health_check_enabled');
});
}
if (! Schema::hasColumn('applications', 'health_check_command')) {
Schema::table('applications', function (Blueprint $table) {
$table->text('health_check_command')->nullable()->after('health_check_type');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('applications', 'health_check_type')) {
Schema::table('applications', function (Blueprint $table) {
$table->dropColumn('health_check_type');
});
}
if (Schema::hasColumn('applications', 'health_check_command')) {
Schema::table('applications', function (Blueprint $table) {
$table->dropColumn('health_check_command');
});
}
}
};

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@ class ApplicationSeeder extends Seeder
'git_repository' => 'coollabsio/coolify-examples',
'git_branch' => 'v4.x',
'base_directory' => '/docker-compose',
'docker_compose_location' => 'docker-compose-test.yaml',
'docker_compose_location' => '/docker-compose-test.yaml',
'build_pack' => 'dockercompose',
'ports_exposes' => '80',
'environment_id' => 1,

View file

@ -26,12 +26,14 @@ class CaSslCertSeeder extends Seeder
}
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($caCert->ssl_certificate);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$caCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);

View file

@ -0,0 +1,210 @@
services:
coolify:
image: coolify:dev
pull_policy: never
build:
context: .
dockerfile: ./docker/development/Dockerfile
args:
- USER_ID=${USERID:-1000}
- GROUP_ID=${GROUPID:-1000}
ports:
- "${APP_PORT:-8000}:8080"
environment:
AUTORUN_ENABLED: false
PUSHER_HOST: "${PUSHER_HOST}"
PUSHER_PORT: "${PUSHER_PORT}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
healthcheck:
test: curl -sf http://127.0.0.1:8080/api/health || exit 1
interval: 5s
retries: 10
timeout: 2s
volumes:
- .:/var/www/html/:cached
- dev_backups_data:/var/www/html/storage/app/backups
networks:
- coolify
postgres:
pull_policy: always
ports:
- "${FORWARD_DB_PORT:-5432}:5432"
env_file:
- .env
environment:
POSTGRES_USER: "${DB_USERNAME:-coolify}"
POSTGRES_PASSWORD: "${DB_PASSWORD:-password}"
POSTGRES_DB: "${DB_DATABASE:-coolify}"
POSTGRES_HOST_AUTH_METHOD: "trust"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 5s
retries: 10
timeout: 2s
volumes:
- dev_postgres_data:/var/lib/postgresql/data
redis:
pull_policy: always
ports:
- "${FORWARD_REDIS_PORT:-6379}:6379"
env_file:
- .env
healthcheck:
test: redis-cli ping
interval: 5s
retries: 10
timeout: 2s
volumes:
- dev_redis_data:/data
soketi:
image: coolify-realtime:dev
pull_policy: never
build:
context: .
dockerfile: ./docker/coolify-realtime/Dockerfile
env_file:
- .env
ports:
- "${FORWARD_SOKETI_PORT:-6001}:6001"
- "6002:6002"
volumes:
- ./storage:/var/www/html/storage
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
environment:
SOKETI_DEBUG: "false"
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s
retries: 10
timeout: 2s
entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"]
vite:
image: node:24-alpine
pull_policy: always
container_name: coolify-vite
working_dir: /var/www/html
environment:
VITE_HOST: "${VITE_HOST:-localhost}"
VITE_PORT: "${VITE_PORT:-5173}"
ports:
- "${VITE_PORT:-5173}:${VITE_PORT:-5173}"
volumes:
- .:/var/www/html/:cached
command: sh -c "npm install && npm run dev"
networks:
- coolify
testing-host:
image: coolify-testing-host:dev
pull_policy: never
build:
context: .
dockerfile: ./docker/testing-host/Dockerfile
init: true
container_name: coolify-testing-host
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- dev_coolify_data:/data/coolify
- dev_backups_data:/data/coolify/backups
- dev_postgres_data:/data/coolify/_volumes/database
- dev_redis_data:/data/coolify/_volumes/redis
- dev_minio_data:/data/coolify/_volumes/minio
networks:
- coolify
mailpit:
image: axllent/mailpit:latest
pull_policy: always
container_name: coolify-mail
ports:
- "${FORWARD_MAILPIT_PORT:-1025}:1025"
- "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025"
networks:
- coolify
# maxio:
# image: ghcr.io/coollabsio/maxio
# pull_policy: always
# container_name: coolify-maxio
# ports:
# - "${FORWARD_MAXIO_PORT:-9000}:9000"
# environment:
# MAXIO_ACCESS_KEY: "${MAXIO_ACCESS_KEY:-maxioadmin}"
# MAXIO_SECRET_KEY: "${MAXIO_SECRET_KEY:-maxioadmin}"
# volumes:
# - dev_maxio_data:/data
# networks:
# - coolify
minio:
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
pull_policy: always
container_name: coolify-minio
command: server /data --console-address ":9001"
ports:
- "${FORWARD_MINIO_PORT:-9000}:9000"
- "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001"
environment:
MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}"
volumes:
- dev_minio_data:/data
- dev_maxio_data:/data
networks:
- coolify
# maxio-init:
# image: minio/mc:latest
# pull_policy: always
# container_name: coolify-maxio-init
# restart: no
# depends_on:
# - maxio
# entrypoint: >
# /bin/sh -c "
# echo 'Waiting for MaxIO to be ready...';
# until mc alias set local http://coolify-maxio:9000 maxioadmin maxioadmin 2>/dev/null; do
# echo 'MaxIO not ready yet, waiting...';
# sleep 2;
# done;
# echo 'MaxIO is ready, creating bucket if needed...';
# mc mb local/local --ignore-existing;
# echo 'MaxIO initialization complete - bucket local is ready';
# "
# networks:
# - coolify
minio-init:
image: minio/mc:latest
pull_policy: always
container_name: coolify-minio-init
restart: no
depends_on:
- minio
entrypoint: >
/bin/sh -c "
echo 'Waiting for MinIO to be ready...';
until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do
echo 'MinIO not ready yet, waiting...';
sleep 2;
done;
echo 'MinIO is ready, creating bucket if needed...';
mc mb local/local --ignore-existing;
echo 'MinIO initialization complete - bucket local is ready';
"
networks:
- coolify
volumes:
dev_backups_data:
dev_postgres_data:
dev_redis_data:
dev_coolify_data:
dev_minio_data:
dev_maxio_data:
networks:
coolify:
name: coolify
external: false

View file

@ -26,6 +26,8 @@ services:
volumes:
- .:/var/www/html/:cached
- dev_backups_data:/var/www/html/storage/app/backups
networks:
- coolify
postgres:
pull_policy: always
ports:
@ -76,6 +78,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

View file

@ -72,6 +72,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

View file

@ -113,6 +113,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

View file

@ -47,6 +47,9 @@ RUN apk add --no-cache \
lsof \
vim
# Install PHP extensions
RUN install-php-extensions sockets
# Configure shell aliases
RUN echo "alias ll='ls -al'" >> /etc/profile && \
echo "alias a='php artisan'" >> /etc/profile && \

View file

@ -8049,6 +8049,698 @@
]
}
},
"\/applications\/{uuid}\/scheduled-tasks": {
"get": {
"tags": [
"Scheduled Tasks"
],
"summary": "List Tasks",
"description": "List all scheduled tasks for an application.",
"operationId": "list-scheduled-tasks-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Get all scheduled tasks for an application.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/ScheduledTask"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Scheduled Tasks"
],
"summary": "Create Task",
"description": "Create a new scheduled task for an application.",
"operationId": "create-scheduled-task-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Scheduled task data",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"name",
"command",
"frequency"
],
"properties": {
"name": {
"type": "string",
"description": "The name of the scheduled task."
},
"command": {
"type": "string",
"description": "The command to execute."
},
"frequency": {
"type": "string",
"description": "The frequency of the scheduled task."
},
"container": {
"type": "string",
"nullable": true,
"description": "The container where the command should be executed."
},
"timeout": {
"type": "integer",
"description": "The timeout of the scheduled task in seconds.",
"default": 300
},
"enabled": {
"type": "boolean",
"description": "The flag to indicate if the scheduled task is enabled.",
"default": true
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Scheduled task created.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/ScheduledTask"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": {
"delete": {
"tags": [
"Scheduled Tasks"
],
"summary": "Delete Task",
"description": "Delete a scheduled task for an application.",
"operationId": "delete-scheduled-task-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "task_uuid",
"in": "path",
"description": "UUID of the scheduled task.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Scheduled task deleted.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Scheduled task deleted."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Scheduled Tasks"
],
"summary": "Update Task",
"description": "Update a scheduled task for an application.",
"operationId": "update-scheduled-task-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "task_uuid",
"in": "path",
"description": "UUID of the scheduled task.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Scheduled task data",
"required": true,
"content": {
"application\/json": {
"schema": {
"properties": {
"name": {
"type": "string",
"description": "The name of the scheduled task."
},
"command": {
"type": "string",
"description": "The command to execute."
},
"frequency": {
"type": "string",
"description": "The frequency of the scheduled task."
},
"container": {
"type": "string",
"nullable": true,
"description": "The container where the command should be executed."
},
"timeout": {
"type": "integer",
"description": "The timeout of the scheduled task in seconds.",
"default": 300
},
"enabled": {
"type": "boolean",
"description": "The flag to indicate if the scheduled task is enabled.",
"default": true
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Scheduled task updated.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/ScheduledTask"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": {
"get": {
"tags": [
"Scheduled Tasks"
],
"summary": "List Executions",
"description": "List all executions for a scheduled task on an application.",
"operationId": "list-scheduled-task-executions-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "task_uuid",
"in": "path",
"description": "UUID of the scheduled task.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Get all executions for a scheduled task.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/ScheduledTaskExecution"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/scheduled-tasks": {
"get": {
"tags": [
"Scheduled Tasks"
],
"summary": "List Tasks",
"description": "List all scheduled tasks for a service.",
"operationId": "list-scheduled-tasks-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Get all scheduled tasks for a service.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/ScheduledTask"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Scheduled Tasks"
],
"summary": "Create Task",
"description": "Create a new scheduled task for a service.",
"operationId": "create-scheduled-task-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Scheduled task data",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"name",
"command",
"frequency"
],
"properties": {
"name": {
"type": "string",
"description": "The name of the scheduled task."
},
"command": {
"type": "string",
"description": "The command to execute."
},
"frequency": {
"type": "string",
"description": "The frequency of the scheduled task."
},
"container": {
"type": "string",
"nullable": true,
"description": "The container where the command should be executed."
},
"timeout": {
"type": "integer",
"description": "The timeout of the scheduled task in seconds.",
"default": 300
},
"enabled": {
"type": "boolean",
"description": "The flag to indicate if the scheduled task is enabled.",
"default": true
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Scheduled task created.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/ScheduledTask"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": {
"delete": {
"tags": [
"Scheduled Tasks"
],
"summary": "Delete Task",
"description": "Delete a scheduled task for a service.",
"operationId": "delete-scheduled-task-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "task_uuid",
"in": "path",
"description": "UUID of the scheduled task.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Scheduled task deleted.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Scheduled task deleted."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Scheduled Tasks"
],
"summary": "Update Task",
"description": "Update a scheduled task for a service.",
"operationId": "update-scheduled-task-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "task_uuid",
"in": "path",
"description": "UUID of the scheduled task.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Scheduled task data",
"required": true,
"content": {
"application\/json": {
"schema": {
"properties": {
"name": {
"type": "string",
"description": "The name of the scheduled task."
},
"command": {
"type": "string",
"description": "The command to execute."
},
"frequency": {
"type": "string",
"description": "The frequency of the scheduled task."
},
"container": {
"type": "string",
"nullable": true,
"description": "The container where the command should be executed."
},
"timeout": {
"type": "integer",
"description": "The timeout of the scheduled task in seconds.",
"default": 300
},
"enabled": {
"type": "boolean",
"description": "The flag to indicate if the scheduled task is enabled.",
"default": true
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Scheduled task updated.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/ScheduledTask"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": {
"get": {
"tags": [
"Scheduled Tasks"
],
"summary": "List Executions",
"description": "List all executions for a scheduled task on a service.",
"operationId": "list-scheduled-task-executions-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "task_uuid",
"in": "path",
"description": "UUID of the scheduled task.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Get all executions for a scheduled task.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/ScheduledTaskExecution"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/security\/keys": {
"get": {
"tags": [
@ -10332,6 +11024,19 @@
"type": "integer",
"description": "Health check start period in seconds."
},
"health_check_type": {
"type": "string",
"description": "Health check type: http or cmd.",
"enum": [
"http",
"cmd"
]
},
"health_check_command": {
"type": "string",
"nullable": true,
"description": "Health check command for CMD type."
},
"limits_memory": {
"type": "string",
"description": "Memory limit."
@ -10771,6 +11476,110 @@
},
"type": "object"
},
"ScheduledTask": {
"description": "Scheduled Task model",
"properties": {
"id": {
"type": "integer",
"description": "The unique identifier of the scheduled task in the database."
},
"uuid": {
"type": "string",
"description": "The unique identifier of the scheduled task."
},
"enabled": {
"type": "boolean",
"description": "The flag to indicate if the scheduled task is enabled."
},
"name": {
"type": "string",
"description": "The name of the scheduled task."
},
"command": {
"type": "string",
"description": "The command to execute."
},
"frequency": {
"type": "string",
"description": "The frequency of the scheduled task."
},
"container": {
"type": "string",
"nullable": true,
"description": "The container where the command should be executed."
},
"timeout": {
"type": "integer",
"description": "The timeout of the scheduled task in seconds."
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "The date and time when the scheduled task was created."
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "The date and time when the scheduled task was last updated."
}
},
"type": "object"
},
"ScheduledTaskExecution": {
"description": "Scheduled Task Execution model",
"properties": {
"uuid": {
"type": "string",
"description": "The unique identifier of the execution."
},
"status": {
"type": "string",
"enum": [
"success",
"failed",
"running"
],
"description": "The status of the execution."
},
"message": {
"type": "string",
"nullable": true,
"description": "The output message of the execution."
},
"retry_count": {
"type": "integer",
"description": "The number of retries."
},
"duration": {
"type": "number",
"nullable": true,
"description": "Duration in seconds."
},
"started_at": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When the execution started."
},
"finished_at": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When the execution finished."
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "When the record was created."
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "When the record was last updated."
}
},
"type": "object"
},
"Server": {
"description": "Server model",
"properties": {
@ -11283,6 +12092,10 @@
"name": "Resources",
"description": "Resources"
},
{
"name": "Scheduled Tasks",
"description": "Scheduled Tasks"
},
{
"name": "Private Keys",
"description": "Private Keys"
@ -11300,4 +12113,4 @@
"description": "Teams"
}
]
}
}

View file

@ -5085,6 +5085,478 @@ paths:
security:
-
bearerAuth: []
'/applications/{uuid}/scheduled-tasks':
get:
tags:
- 'Scheduled Tasks'
summary: 'List Tasks'
description: 'List all scheduled tasks for an application.'
operationId: list-scheduled-tasks-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
responses:
'200':
description: 'Get all scheduled tasks for an application.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ScheduledTask'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- 'Scheduled Tasks'
summary: 'Create Task'
description: 'Create a new scheduled task for an application.'
operationId: create-scheduled-task-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
requestBody:
description: 'Scheduled task data'
required: true
content:
application/json:
schema:
required:
- name
- command
- frequency
properties:
name:
type: string
description: 'The name of the scheduled task.'
command:
type: string
description: 'The command to execute.'
frequency:
type: string
description: 'The frequency of the scheduled task.'
container:
type: string
nullable: true
description: 'The container where the command should be executed.'
timeout:
type: integer
description: 'The timeout of the scheduled task in seconds.'
default: 300
enabled:
type: boolean
description: 'The flag to indicate if the scheduled task is enabled.'
default: true
type: object
responses:
'201':
description: 'Scheduled task created.'
content:
application/json:
schema:
$ref: '#/components/schemas/ScheduledTask'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/applications/{uuid}/scheduled-tasks/{task_uuid}':
delete:
tags:
- 'Scheduled Tasks'
summary: 'Delete Task'
description: 'Delete a scheduled task for an application.'
operationId: delete-scheduled-task-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: task_uuid
in: path
description: 'UUID of the scheduled task.'
required: true
schema:
type: string
responses:
'200':
description: 'Scheduled task deleted.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'Scheduled task deleted.' }
type: object
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
patch:
tags:
- 'Scheduled Tasks'
summary: 'Update Task'
description: 'Update a scheduled task for an application.'
operationId: update-scheduled-task-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: task_uuid
in: path
description: 'UUID of the scheduled task.'
required: true
schema:
type: string
requestBody:
description: 'Scheduled task data'
required: true
content:
application/json:
schema:
properties:
name:
type: string
description: 'The name of the scheduled task.'
command:
type: string
description: 'The command to execute.'
frequency:
type: string
description: 'The frequency of the scheduled task.'
container:
type: string
nullable: true
description: 'The container where the command should be executed.'
timeout:
type: integer
description: 'The timeout of the scheduled task in seconds.'
default: 300
enabled:
type: boolean
description: 'The flag to indicate if the scheduled task is enabled.'
default: true
type: object
responses:
'200':
description: 'Scheduled task updated.'
content:
application/json:
schema:
$ref: '#/components/schemas/ScheduledTask'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/applications/{uuid}/scheduled-tasks/{task_uuid}/executions':
get:
tags:
- 'Scheduled Tasks'
summary: 'List Executions'
description: 'List all executions for a scheduled task on an application.'
operationId: list-scheduled-task-executions-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: task_uuid
in: path
description: 'UUID of the scheduled task.'
required: true
schema:
type: string
responses:
'200':
description: 'Get all executions for a scheduled task.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ScheduledTaskExecution'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
'/services/{uuid}/scheduled-tasks':
get:
tags:
- 'Scheduled Tasks'
summary: 'List Tasks'
description: 'List all scheduled tasks for a service.'
operationId: list-scheduled-tasks-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
responses:
'200':
description: 'Get all scheduled tasks for a service.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ScheduledTask'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- 'Scheduled Tasks'
summary: 'Create Task'
description: 'Create a new scheduled task for a service.'
operationId: create-scheduled-task-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
requestBody:
description: 'Scheduled task data'
required: true
content:
application/json:
schema:
required:
- name
- command
- frequency
properties:
name:
type: string
description: 'The name of the scheduled task.'
command:
type: string
description: 'The command to execute.'
frequency:
type: string
description: 'The frequency of the scheduled task.'
container:
type: string
nullable: true
description: 'The container where the command should be executed.'
timeout:
type: integer
description: 'The timeout of the scheduled task in seconds.'
default: 300
enabled:
type: boolean
description: 'The flag to indicate if the scheduled task is enabled.'
default: true
type: object
responses:
'201':
description: 'Scheduled task created.'
content:
application/json:
schema:
$ref: '#/components/schemas/ScheduledTask'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/services/{uuid}/scheduled-tasks/{task_uuid}':
delete:
tags:
- 'Scheduled Tasks'
summary: 'Delete Task'
description: 'Delete a scheduled task for a service.'
operationId: delete-scheduled-task-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
-
name: task_uuid
in: path
description: 'UUID of the scheduled task.'
required: true
schema:
type: string
responses:
'200':
description: 'Scheduled task deleted.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'Scheduled task deleted.' }
type: object
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
patch:
tags:
- 'Scheduled Tasks'
summary: 'Update Task'
description: 'Update a scheduled task for a service.'
operationId: update-scheduled-task-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
-
name: task_uuid
in: path
description: 'UUID of the scheduled task.'
required: true
schema:
type: string
requestBody:
description: 'Scheduled task data'
required: true
content:
application/json:
schema:
properties:
name:
type: string
description: 'The name of the scheduled task.'
command:
type: string
description: 'The command to execute.'
frequency:
type: string
description: 'The frequency of the scheduled task.'
container:
type: string
nullable: true
description: 'The container where the command should be executed.'
timeout:
type: integer
description: 'The timeout of the scheduled task in seconds.'
default: 300
enabled:
type: boolean
description: 'The flag to indicate if the scheduled task is enabled.'
default: true
type: object
responses:
'200':
description: 'Scheduled task updated.'
content:
application/json:
schema:
$ref: '#/components/schemas/ScheduledTask'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/services/{uuid}/scheduled-tasks/{task_uuid}/executions':
get:
tags:
- 'Scheduled Tasks'
summary: 'List Executions'
description: 'List all executions for a scheduled task on a service.'
operationId: list-scheduled-task-executions-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
-
name: task_uuid
in: path
description: 'UUID of the scheduled task.'
required: true
schema:
type: string
responses:
'200':
description: 'Get all executions for a scheduled task.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ScheduledTaskExecution'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/security/keys:
get:
tags:
@ -6488,6 +6960,16 @@ components:
health_check_start_period:
type: integer
description: 'Health check start period in seconds.'
health_check_type:
type: string
description: 'Health check type: http or cmd.'
enum:
- http
- cmd
health_check_command:
type: string
nullable: true
description: 'Health check command for CMD type.'
limits_memory:
type: string
description: 'Memory limit.'
@ -6807,6 +7289,86 @@ components:
description:
type: string
type: object
ScheduledTask:
description: 'Scheduled Task model'
properties:
id:
type: integer
description: 'The unique identifier of the scheduled task in the database.'
uuid:
type: string
description: 'The unique identifier of the scheduled task.'
enabled:
type: boolean
description: 'The flag to indicate if the scheduled task is enabled.'
name:
type: string
description: 'The name of the scheduled task.'
command:
type: string
description: 'The command to execute.'
frequency:
type: string
description: 'The frequency of the scheduled task.'
container:
type: string
nullable: true
description: 'The container where the command should be executed.'
timeout:
type: integer
description: 'The timeout of the scheduled task in seconds.'
created_at:
type: string
format: date-time
description: 'The date and time when the scheduled task was created.'
updated_at:
type: string
format: date-time
description: 'The date and time when the scheduled task was last updated.'
type: object
ScheduledTaskExecution:
description: 'Scheduled Task Execution model'
properties:
uuid:
type: string
description: 'The unique identifier of the execution.'
status:
type: string
enum:
- success
- failed
- running
description: 'The status of the execution.'
message:
type: string
nullable: true
description: 'The output message of the execution.'
retry_count:
type: integer
description: 'The number of retries.'
duration:
type: number
nullable: true
description: 'Duration in seconds.'
started_at:
type: string
format: date-time
nullable: true
description: 'When the execution started.'
finished_at:
type: string
format: date-time
nullable: true
description: 'When the execution finished.'
created_at:
type: string
format: date-time
description: 'When the record was created.'
updated_at:
type: string
format: date-time
description: 'When the record was last updated.'
type: object
Server:
description: 'Server model'
properties:
@ -7161,6 +7723,9 @@ tags:
-
name: Resources
description: Resources
-
name: 'Scheduled Tasks'
description: 'Scheduled Tasks'
-
name: 'Private Keys'
description: 'Private Keys'

View file

@ -72,6 +72,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

View file

@ -113,6 +113,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

65
package-lock.json generated
View file

@ -10,7 +10,8 @@
"@tailwindcss/typography": "0.5.16",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"ioredis": "5.6.1"
"ioredis": "5.6.1",
"playwright": "^1.58.2"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
@ -949,7 +950,8 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.10",
@ -1402,8 +1404,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
@ -1556,6 +1557,7 @@
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
@ -1570,6 +1572,7 @@
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
}
@ -2330,7 +2333,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2338,6 +2340,50 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -2407,7 +2453,6 @@
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tweetnacl": "^1.0.3"
}
@ -2512,6 +2557,7 @@
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
@ -2556,8 +2602,7 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
@ -2609,7 +2654,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -2709,7 +2753,6 @@
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
@ -2732,6 +2775,7 @@
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@ -2753,6 +2797,7 @@
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.4.0"
}

View file

@ -24,6 +24,7 @@
"@tailwindcss/typography": "0.5.16",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"ioredis": "5.6.1"
"ioredis": "5.6.1",
"playwright": "^1.58.2"
}
}
}

View file

@ -7,17 +7,20 @@
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
<testsuite name="v4">
<directory suffix="Test.php">./tests/v4</directory>
</testsuite>
</testsuites>
<coverage/>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="testing"/>
<env name="DB_TEST_DATABASE" value="coolify"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="CACHE_DRIVER" value="array" force="true"/>
<env name="DB_CONNECTION" value="testing" force="true"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
<source>

BIN
public/svgs/spacebot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View file

@ -1,7 +1,9 @@
<?php
function getOldOrLocal($key, $localValue)
{
return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : '');
if (! function_exists('getOldOrLocal')) {
function getOldOrLocal($key, $localValue)
{
return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : '');
}
}
$name = getOldOrLocal('name', 'test3 normal user');

View file

@ -25,7 +25,7 @@
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</svg>
{{-- Eye-off icon (shown when password is visible) --}}
<svg x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
<svg x-cloak x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />

View file

@ -19,6 +19,10 @@
href="{{ route('settings.oauth') }}">
OAuth
</a>
<a class="{{ request()->routeIs('settings.scheduled-jobs') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('settings.scheduled-jobs') }}">
Scheduled Jobs
</a>
<div class="flex-1"></div>
</nav>
</div>

View file

@ -3,15 +3,11 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">419</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">This page is definitely old, not like you!</h1>
<p class="text-base leading-7 dark:text-neutral-300 text-black">Sorry, we couldn't find the page you're looking
for.
<p class="text-base leading-7 dark:text-neutral-300 text-black">Your session has expired. Please log in again to continue.
</p>
<div class="flex items-center mt-10 gap-x-2">
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
<a href="/login">
<x-forms.button>Back to Login</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact
support

View file

@ -699,15 +699,21 @@
class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-warning-600 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
</svg>
</div>
@if (! empty($item['logo']))
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center overflow-hidden">
<img src="{{ asset($item['logo']) }}" alt="{{ $item['name'] }}" class="w-7 h-7 object-contain">
</div>
@else
<div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-warning-600 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
</svg>
</div>
@endif
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<div class="font-medium text-neutral-900 dark:text-white truncate">
@ -808,15 +814,22 @@
class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-warning-600 dark:text-warning-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<template x-if="item.logo">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center overflow-hidden">
<img :src="'/' + item.logo" :alt="item.name" class="w-7 h-7 object-contain">
</div>
</template>
<template x-if="!item.logo">
<div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-warning-600 dark:text-warning-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
</template>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<div class="font-medium text-neutral-900 dark:text-white truncate"

View file

@ -1,7 +1,7 @@
<nav wire:poll.10000ms="checkStatus" class="pb-6">
<x-resources.breadcrumbs :resource="$application" :parameters="$parameters" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
<div class="navbar-main">
<nav class="flex shrink-0 gap-4 items-center whitespace-nowrap scrollbar min-h-10">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('project.application.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.application.configuration', $parameters) }}">
Configuration

View file

@ -9,7 +9,7 @@
<h1>{{ $title }}</h1>
<x-resources.breadcrumbs :resource="$service" :parameters="$parameters" />
<div class="navbar-main" x-data">
<nav class="flex shrink-0 gap-4 items-center whitespace-nowrap scrollbar min-h-10">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('project.service.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.service.configuration', $parameters) }}">
<button>Configuration</button>

View file

@ -20,25 +20,51 @@
<p>A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.</p>
</x-callout>
@endif
{{-- Healthcheck Type Selector --}}
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckMethod" label="Method" required>
<option value="GET">GET</option>
<option value="POST">POST</option>
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckType" label="Type" required wire:model.live="healthCheckType">
<option value="http">HTTP</option>
<option value="cmd">CMD</option>
</x-forms.select>
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckScheme" label="Scheme" required>
<option value="http">http</option>
<option value="https">https</option>
</x-forms.select>
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckHost" placeholder="localhost" label="Host" required />
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckPort"
helper="If no port is defined, the first exposed port will be used." placeholder="80" label="Port" />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckPath" placeholder="/health" label="Path" required />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckReturnCode" placeholder="200" label="Return Code"
required />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckResponseText" placeholder="OK" label="Response Text" />
</div>
@if ($healthCheckType === 'http')
{{-- HTTP Healthcheck Fields --}}
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckMethod" label="Method" required>
<option value="GET">GET</option>
<option value="POST">POST</option>
</x-forms.select>
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckScheme" label="Scheme" required>
<option value="http">http</option>
<option value="https">https</option>
</x-forms.select>
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckHost" placeholder="localhost" label="Host" required />
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckPort"
helper="If no port is defined, the first exposed port will be used." placeholder="80" label="Port" />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckPath" placeholder="/health" label="Path" required />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckReturnCode" placeholder="200" label="Return Code"
required />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckResponseText" placeholder="OK" label="Response Text" />
</div>
@else
{{-- CMD Healthcheck Fields --}}
<x-callout type="warning" title="Caution">
<p>This command runs inside the container on every health check interval. Shell operators (;, |, &amp;, $, &gt;, &lt;) are not allowed.</p>
</x-callout>
<div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckCommand"
label="Command"
placeholder="pg_isready -U postgres"
helper="A simple command to run inside the container. Must exit with code 0 on success. Shell operators like ;, |, &&, $() are not allowed."
:required="$healthCheckType === 'cmd'" />
</div>
@endif
{{-- Common timing fields (used by both types) --}}
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number" id="healthCheckInterval" placeholder="30"
label="Interval (s)" required />
@ -49,4 +75,4 @@
label="Start Period (s)" required />
</div>
</div>
</form>
</form>

View file

@ -27,6 +27,22 @@
<div class="mt-1 mb-6">Configure Docker cleanup settings for your server.</div>
</div>
@if ($this->isCleanupStale)
<div class="mb-4">
<x-callout type="warning" title="Docker Cleanup May Be Stalled">
<p>The last Docker cleanup ran {{ $this->lastExecutionTime ?? 'unknown time' }} ago,
which is longer than expected for the configured frequency.</p>
@if (!$this->isSchedulerHealthy)
<p class="mt-1">The scheduled job manager appears to be inactive. This may indicate
a stale Redis lock is blocking all scheduled jobs.</p>
@endif
<p class="mt-2">To resolve, run on your Coolify instance:
<code class="bg-black/10 dark:bg-white/10 px-1 rounded">php artisan cleanup:redis --clear-locks</code>
</p>
</x-callout>
</div>
@endif
<div class="flex flex-col gap-2">
<div class="flex gap-4">
<h3>Cleanup Configuration</h3>

View file

@ -0,0 +1,260 @@
<div>
<x-slot:title>
Scheduled Job Issues | Coolify
</x-slot>
<x-settings.navbar />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'executions' }"
class="flex flex-col gap-8">
<div>
<div class="flex items-center gap-2">
<h2>Scheduled Job Issues</h2>
<x-forms.button wire:click="refresh">Refresh</x-forms.button>
</div>
<div class="pb-4">Shows failed executions, skipped jobs, and scheduler health.</div>
</div>
{{-- Tab Buttons --}}
<div class="flex flex-row gap-4">
<div @class([
'box-without-bg cursor-pointer dark:bg-coolgray-100 dark:text-white w-full text-center items-center justify-center',
])
:class="activeTab === 'executions' && 'dark:bg-coollabs bg-coollabs text-white'"
@click="activeTab = 'executions'; window.location.hash = 'executions'">
Failures ({{ $executions->count() }})
</div>
<div @class([
'box-without-bg cursor-pointer dark:bg-coolgray-100 dark:text-white w-full text-center items-center justify-center',
])
:class="activeTab === 'scheduler-runs' && 'dark:bg-coollabs bg-coollabs text-white'"
@click="activeTab = 'scheduler-runs'; window.location.hash = 'scheduler-runs'">
Scheduler Runs ({{ $managerRuns->count() }})
</div>
<div @class([
'box-without-bg cursor-pointer dark:bg-coolgray-100 dark:text-white w-full text-center items-center justify-center',
])
:class="activeTab === 'skipped-jobs' && 'dark:bg-coollabs bg-coollabs text-white'"
@click="activeTab = 'skipped-jobs'; window.location.hash = 'skipped-jobs'">
Skipped Jobs ({{ $skipLogs->count() }})
</div>
</div>
{{-- Executions Tab --}}
<div x-show="activeTab === 'executions'" x-cloak>
{{-- Filters --}}
<div class="flex gap-4 flex-wrap mb-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Type</label>
<select wire:model.live="filterType"
class="w-40 border bg-white dark:bg-coolgray-100 border-gray-300 dark:border-coolgray-400 rounded-md text-sm">
<option value="all">All Types</option>
<option value="backup">Backups</option>
<option value="task">Tasks</option>
<option value="cleanup">Docker Cleanup</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Time Range</label>
<select wire:model.live="filterDate"
class="w-40 border bg-white dark:bg-coolgray-100 border-gray-300 dark:border-coolgray-400 rounded-md text-sm">
<option value="last_24h">Last 24 Hours</option>
<option value="last_7d">Last 7 Days</option>
<option value="last_30d">Last 30 Days</option>
<option value="all">All Time</option>
</select>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs uppercase bg-gray-50 dark:bg-coolgray-200">
<tr>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Resource</th>
<th class="px-4 py-3">Server</th>
<th class="px-4 py-3">Started</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Message</th>
</tr>
</thead>
<tbody>
@forelse($executions as $execution)
<tr wire:key="exec-{{ $execution['type'] }}-{{ $execution['id'] }}"
class="border-b border-gray-200 dark:border-coolgray-400 hover:bg-gray-50 dark:hover:bg-coolgray-200">
<td class="px-4 py-3">
@php
$typeLabel = match($execution['type']) {
'backup' => 'Backup',
'task' => 'Task',
'cleanup' => 'Cleanup',
default => ucfirst($execution['type']),
};
$typeBg = match($execution['type']) {
'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
'cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300',
};
@endphp
<span class="px-2 py-1 rounded-md text-xs font-medium {{ $typeBg }}">
{{ $typeLabel }}
</span>
</td>
<td class="px-4 py-3">
{{ $execution['resource_name'] }}
@if($execution['resource_type'])
<span class="text-xs text-gray-500">({{ $execution['resource_type'] }})</span>
@endif
</td>
<td class="px-4 py-3">{{ $execution['server_name'] }}</td>
<td class="px-4 py-3 whitespace-nowrap">
{{ $execution['created_at']->diffForHumans() }}
<span class="block text-xs text-gray-500">{{ $execution['created_at']->format('M d H:i') }}</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
@if($execution['finished_at'] && $execution['created_at'])
{{ \Carbon\Carbon::parse($execution['created_at'])->diffInSeconds(\Carbon\Carbon::parse($execution['finished_at'])) }}s
@elseif($execution['status'] === 'running')
<x-loading class="w-4 h-4" />
@else
-
@endif
</td>
<td class="px-4 py-3 max-w-xs truncate" title="{{ $execution['message'] }}">
{{ \Illuminate\Support\Str::limit($execution['message'], 80) }}
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500">
No failures found for the selected filters.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Scheduler Runs Tab --}}
<div x-show="activeTab === 'scheduler-runs'" x-cloak>
<div class="pb-4 text-sm text-gray-500">Shows when the ScheduledJobManager executed. Gaps indicate lock conflicts or missed runs.</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs uppercase bg-gray-50 dark:bg-coolgray-200">
<tr>
<th class="px-4 py-3">Time</th>
<th class="px-4 py-3">Event</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Dispatched</th>
<th class="px-4 py-3">Skipped</th>
</tr>
</thead>
<tbody>
@forelse($managerRuns as $run)
<tr wire:key="run-{{ $loop->index }}"
class="border-b border-gray-200 dark:border-coolgray-400">
<td class="px-4 py-2 whitespace-nowrap text-xs">{{ $run['timestamp'] }}</td>
<td class="px-4 py-2">{{ $run['message'] }}</td>
<td class="px-4 py-2">
@if($run['duration_ms'] !== null)
{{ $run['duration_ms'] }}ms
@else
-
@endif
</td>
<td class="px-4 py-2">{{ $run['dispatched'] ?? '-' }}</td>
<td class="px-4 py-2">
@if(($run['skipped'] ?? 0) > 0)
<span class="text-warning">{{ $run['skipped'] }}</span>
@else
{{ $run['skipped'] ?? '-' }}
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-4 text-center text-gray-500">
No scheduler run logs found. Logs appear after the ScheduledJobManager runs.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Skipped Jobs Tab --}}
<div x-show="activeTab === 'skipped-jobs'" x-cloak>
<div class="pb-4 text-sm text-gray-500">Jobs that were not dispatched because conditions were not met.</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs uppercase bg-gray-50 dark:bg-coolgray-200">
<tr>
<th class="px-4 py-3">Time</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Reason</th>
<th class="px-4 py-3">Details</th>
</tr>
</thead>
<tbody>
@forelse($skipLogs as $skip)
<tr wire:key="skip-{{ $loop->index }}"
class="border-b border-gray-200 dark:border-coolgray-400">
<td class="px-4 py-2 whitespace-nowrap text-xs">{{ $skip['timestamp'] }}</td>
<td class="px-4 py-2">
@php
$skipTypeBg = match($skip['type']) {
'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
'docker_cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300',
};
@endphp
<span class="px-2 py-1 rounded-md text-xs font-medium {{ $skipTypeBg }}">
{{ ucfirst(str_replace('_', ' ', $skip['type'])) }}
</span>
</td>
<td class="px-4 py-2">
@php
$reasonLabel = match($skip['reason']) {
'server_not_functional' => 'Server not functional',
'subscription_unpaid' => 'Subscription unpaid',
'database_deleted' => 'Database deleted',
'server_deleted' => 'Server deleted',
'resource_deleted' => 'Resource deleted',
'application_not_running' => 'Application not running',
'service_not_running' => 'Service not running',
default => ucfirst(str_replace('_', ' ', $skip['reason'])),
};
$reasonBg = match($skip['reason']) {
'server_not_functional', 'database_deleted', 'server_deleted', 'resource_deleted' => 'text-red-600 dark:text-red-400',
'subscription_unpaid' => 'text-warning',
'application_not_running', 'service_not_running' => 'text-orange-600 dark:text-orange-400',
default => '',
};
@endphp
<span class="{{ $reasonBg }}">{{ $reasonLabel }}</span>
</td>
<td class="px-4 py-2 text-xs text-gray-500">
@php
$details = collect($skip['context'])
->except(['type', 'skip_reason', 'execution_time'])
->map(fn($v, $k) => str_replace('_', ' ', $k) . ': ' . $v)
->implode(', ');
@endphp
{{ $details }}
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
No skipped jobs found. This means all scheduled jobs passed their conditions.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>

View file

@ -9,6 +9,7 @@ use App\Http\Controllers\Api\HetznerController;
use App\Http\Controllers\Api\OtherController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
use App\Http\Controllers\Api\ScheduledTasksController;
use App\Http\Controllers\Api\SecurityController;
use App\Http\Controllers\Api\ServersController;
use App\Http\Controllers\Api\ServicesController;
@ -54,7 +55,7 @@ Route::group([
Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']);
Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']);
Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:read']);
Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']);
Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']);
Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']);
@ -85,7 +86,7 @@ Route::group([
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']);
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:read']);
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']);
@ -106,7 +107,7 @@ Route::group([
/**
* @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is a unstable duplicate of POST /api/v1/services.
*/
*/
Route::post('/applications/dockercompose', [ApplicationsController::class, 'create_dockercompose_application'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']);
@ -120,9 +121,9 @@ Route::group([
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
@ -151,9 +152,9 @@ Route::group([
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']);
Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']);
@ -168,9 +169,21 @@ Route::group([
Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']);
Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']);
Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']);
Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']);
});
Route::group([

View file

@ -62,6 +62,7 @@ use App\Livewire\Server\Show as ServerShow;
use App\Livewire\Server\Swarm as ServerSwarm;
use App\Livewire\Settings\Advanced as SettingsAdvanced;
use App\Livewire\Settings\Index as SettingsIndex;
use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs;
use App\Livewire\Settings\Updates as SettingsUpdates;
use App\Livewire\SettingsBackup;
use App\Livewire\SettingsEmail;
@ -119,6 +120,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup');
Route::get('/settings/email', SettingsEmail::class)->name('settings.email');
Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth');
Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs');
Route::get('/profile', ProfileIndex::class)->name('profile');

View file

@ -6,13 +6,26 @@
services:
beszel-agent:
image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
environment:
# Required
- LISTEN=/beszel_socket/beszel.sock
- HUB_URL=${HUB_URL?}
- 'TOKEN=${TOKEN?}'
- 'KEY=${KEY?}'
- HUB_URL=$SERVICE_URL_BESZEL
- TOKEN=${TOKEN} # From hub token settings
- KEY=${KEY} # SSH public key(s) from hub
# Optional
- DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
- LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
- SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
- SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
- '/var/run/docker.sock:/var/run/docker.sock:ro'
healthcheck:
test: ['CMD', '/agent', 'health']
interval: 60s
timeout: 20s
retries: 10
start_period: 5s

View file

@ -9,21 +9,41 @@
# Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI)
services:
beszel:
image: 'henrygd/beszel:0.16.1' # Released on 14 Nov 2025
image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026
environment:
- SERVICE_URL_BESZEL_8090
- CONTAINER_DETAILS=${CONTAINER_DETAILS:-true}
- SHARE_ALL_SYSTEMS=${SHARE_ALL_SYSTEMS:-false}
volumes:
- 'beszel_data:/beszel_data'
- 'beszel_socket:/beszel_socket'
healthcheck:
test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
interval: 30s
timeout: 20s
retries: 10
start_period: 5s
beszel-agent:
image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
environment:
# Required
- LISTEN=/beszel_socket/beszel.sock
- HUB_URL=http://beszel:8090
- 'TOKEN=${TOKEN}'
- 'KEY=${KEY}'
- HUB_URL=$SERVICE_URL_BESZEL
- TOKEN=${TOKEN} # From hub token settings
- KEY=${KEY} # SSH public key(s) from hub
# Optional
- DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
- LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
- SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
- SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
- '/var/run/docker.sock:/var/run/docker.sock:ro'
healthcheck:
test: ['CMD', '/agent', 'health']
interval: 60s
timeout: 20s
retries: 10
start_period: 5s

View file

@ -1,9 +1,9 @@
# documentation: https://glitchtip.com
# slogan: GlitchTip is a self-hosted, open-source error tracking system.
# slogan: GlitchTip is a error tracking system.
# category: monitoring
# tags: error, tracking, open-source, self-hosted, sentry
# tags: error, tracking, sentry
# logo: svgs/glitchtip.png
# port: 8080
# port: 8000
services:
postgres:
@ -29,14 +29,14 @@ services:
retries: 10
web:
image: glitchtip/glitchtip
image: glitchtip/glitchtip:6.0
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
- SERVICE_URL_GLITCHTIP_8080
- SERVICE_URL_GLITCHTIP_8000
- DATABASE_URL=postgres://$SERVICE_USER_POSTGRESQL:$SERVICE_PASSWORD_POSTGRESQL@postgres:5432/${POSTGRESQL_DATABASE:-glitchtip}
- SECRET_KEY=$SERVICE_BASE64_64_ENCRYPTION
- EMAIL_URL=${EMAIL_URL:-consolemail://}
@ -53,7 +53,7 @@ services:
retries: 10
worker:
image: glitchtip/glitchtip
image: glitchtip/glitchtip:6.0
command: ./bin/run-celery-with-beat.sh
depends_on:
postgres:
@ -77,7 +77,7 @@ services:
retries: 10
migrate:
image: glitchtip/glitchtip
image: glitchtip/glitchtip:6.0
restart: "no"
depends_on:
postgres:

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://docs.plane.so/self-hosting/methods/docker-compose
# slogan: The open source project management tool
# category: productivity

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://pterodactyl.io/
# slogan: Pterodactyl is a free, open-source game server management panel
# category: media
@ -102,4 +103,4 @@ services:
- MAIL_PORT=$MAIL_PORT
- MAIL_USERNAME=$MAIL_USERNAME
- MAIL_PASSWORD=$MAIL_PASSWORD
- MAIL_ENCRYPTION=$MAIL_ENCRYPTION
- MAIL_ENCRYPTION=$MAIL_ENCRYPTION

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://pterodactyl.io/
# slogan: Pterodactyl is a free, open-source game server management panel
# category: media

View file

@ -0,0 +1,31 @@
# documentation: https://docs.spacebot.sh/docker
# slogan: An agentic AI system with specialized processes for thinking, working, and remembering.
# category: ai
# tags: ai, agent, anthropic, openai, discord, slack, llm, agentic
# logo: svgs/spacebot.png
# port: 19898
services:
spacebot:
image: "ghcr.io/spacedriveapp/spacebot:full"
environment:
- SERVICE_FQDN_SPACEBOT_19898
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}
- SLACK_APP_TOKEN=${SLACK_APP_TOKEN}
- BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY}
- SPACEBOT_CHANNEL_MODEL=${SPACEBOT_CHANNEL_MODEL}
- SPACEBOT_WORKER_MODEL=${SPACEBOT_WORKER_MODEL}
volumes:
- "spacebot-data:/data"
security_opt:
- seccomp=unconfined
shm_size: 1g
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:19898/api/health"]
interval: 30s
timeout: 5s
retries: 3

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
*
!.gitignore

Some files were not shown because too many files have changed in this diff Show more