mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(api): require deploy ability for server side effects
Require deploy permission for API changes that trigger server side effects, including resource deletion, storage access, provider operations, and database imports. Also escape Docker cleanup filters and sanitize log drain server names.
This commit is contained in:
@@ -25,6 +25,8 @@
|
||||
- Verify the exact live flow, persisted state, relevant logs, and queue state when they affect the result.
|
||||
- When the request covers more than one interface or resource type, inventory and verify each supported path.
|
||||
- When a change alters how stored data reaches running containers, simulate the upgrade on the dev VM: start resources on the old code, then migrate and restart them on the new code, and compare the results.
|
||||
- Horizon workers in a dev instance keep the code they loaded. Run `./scripts/dev exec php artisan horizon:terminate` after a code change and before a live test of queued jobs.
|
||||
- To prove that a server side effect is blocked, check the server itself after the request. A model hook or job can be one of several write paths (for example `ServerStorageSaveJob` and `ServerFilesFromServerJob` both write file storages).
|
||||
|
||||
## Preserve product scope
|
||||
- Do not replace required SPA navigation with a full-page redirect to hide a lifecycle or ordering defect.
|
||||
|
||||
@@ -273,11 +273,12 @@ class CleanupDocker implements ShouldBeUnique
|
||||
|
||||
// Add application repo exclusion if there are applications
|
||||
if ($applicationImageRepos->isNotEmpty()) {
|
||||
$grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:@].+'";
|
||||
// The repository names come from application data, so quote the pattern for the shell.
|
||||
$grepCommands .= ' | grep -v -E '.escapeshellarg("^({$appExcludePatterns})[_:@].+");
|
||||
}
|
||||
|
||||
// Add infrastructure image exclusion (matches any registry prefix)
|
||||
$grepCommands .= " | grep -v -E '{$infraExcludePattern}'";
|
||||
$grepCommands .= ' | grep -v -E '.escapeshellarg($infraExcludePattern);
|
||||
|
||||
$commands[] = "docker images --digests --format '{{if eq .Tag \"<none>\"}}{{.Repository}}@{{.Digest}}{{else}}{{.Repository}}:{{.Tag}}{{end}}' | ".
|
||||
$grepCommands.' | '.
|
||||
|
||||
@@ -29,6 +29,7 @@ class StartLogDrain
|
||||
if ($type !== 'none') {
|
||||
StopLogDrain::run($server);
|
||||
}
|
||||
$serverName = $this->fluentBitValue((string) $server->name);
|
||||
try {
|
||||
if ($type === 'none') {
|
||||
return 'No log drain is enabled.';
|
||||
@@ -54,7 +55,7 @@ class StartLogDrain
|
||||
[FILTER]
|
||||
Name modify
|
||||
Match *
|
||||
Set coolify.server_name {$server->name}
|
||||
Set coolify.server_name {$serverName}
|
||||
Rename COOLIFY_APP_NAME coolify.app_name
|
||||
Rename COOLIFY_PROJECT_NAME coolify.project_name
|
||||
Rename COOLIFY_SERVER_IP coolify.server_ip
|
||||
@@ -109,7 +110,7 @@ class StartLogDrain
|
||||
[FILTER]
|
||||
Name modify
|
||||
Match *
|
||||
Set coolify.server_name {$server->name}
|
||||
Set coolify.server_name {$serverName}
|
||||
Rename COOLIFY_APP_NAME coolify.app_name
|
||||
Rename COOLIFY_PROJECT_NAME coolify.project_name
|
||||
Rename COOLIFY_SERVER_IP coolify.server_ip
|
||||
@@ -219,6 +220,14 @@ Files:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fluent Bit reads its config line by line, so a value with a line break could add sections.
|
||||
*/
|
||||
private function fluentBitValue(string $value): string
|
||||
{
|
||||
return trim(preg_replace('/\s*[\x00-\x1F\x7F]+\s*/', ' ', $value));
|
||||
}
|
||||
|
||||
private function logDrainNetworkConnectCommands(Server $server): array
|
||||
{
|
||||
if (! $server->isLogDrainEnabled()) {
|
||||
|
||||
@@ -39,6 +39,7 @@ use Spatie\Url\Url;
|
||||
class ApplicationsController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
use Concerns\RequiresDeployAbility;
|
||||
use Concerns\RequiresDeployForOutsideHostPaths;
|
||||
|
||||
private const APPLICATION_SETTING_FIELDS = [
|
||||
@@ -143,6 +144,21 @@ class ApplicationsController extends Controller
|
||||
return serializeApiResponse($application);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setting or changing a manual webhook secret needs the deploy ability. Sending the stored value
|
||||
* again or clearing it does not.
|
||||
*/
|
||||
private function manualWebhookSecretForbiddenResponse(Request $request, ?Application $application = null): ?JsonResponse
|
||||
{
|
||||
foreach (['manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea'] as $field) {
|
||||
if ($request->filled($field) && $request->input($field) !== $application?->{$field}) {
|
||||
return $this->deployAbilityForbiddenResponse($request, 'A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function applicationSettingsFromRequest(Request $request): array
|
||||
{
|
||||
$settings = [];
|
||||
@@ -394,10 +410,10 @@ class ApplicationsController extends Controller
|
||||
'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'],
|
||||
'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'],
|
||||
'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
||||
// 'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
|
||||
@@ -478,6 +494,10 @@ class ApplicationsController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
@@ -591,10 +611,10 @@ class ApplicationsController extends Controller
|
||||
'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'],
|
||||
'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'],
|
||||
'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
|
||||
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
|
||||
@@ -674,6 +694,10 @@ class ApplicationsController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
@@ -787,10 +811,10 @@ class ApplicationsController extends Controller
|
||||
'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'],
|
||||
'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'],
|
||||
'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
|
||||
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
|
||||
@@ -870,6 +894,10 @@ class ApplicationsController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
@@ -970,10 +998,10 @@ class ApplicationsController extends Controller
|
||||
'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'],
|
||||
'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'],
|
||||
'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
|
||||
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
|
||||
@@ -1036,6 +1064,10 @@ class ApplicationsController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
@@ -1133,10 +1165,10 @@ class ApplicationsController extends Controller
|
||||
'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'],
|
||||
'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'],
|
||||
'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
|
||||
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
|
||||
@@ -1199,6 +1231,10 @@ class ApplicationsController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
@@ -1422,6 +1458,9 @@ class ApplicationsController extends Controller
|
||||
if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) {
|
||||
return $internalNameValidation;
|
||||
}
|
||||
if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request)) {
|
||||
return $forbidden;
|
||||
}
|
||||
if ($type === 'public') {
|
||||
$validationRules = [
|
||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||
@@ -2940,7 +2979,7 @@ class ApplicationsController extends Controller
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete',
|
||||
description: 'Delete application by UUID.',
|
||||
description: 'Delete application by UUID. Needs a token with the write and deploy permissions.',
|
||||
path: '/applications/{uuid}',
|
||||
operationId: 'delete-application-by-uuid',
|
||||
security: [
|
||||
@@ -2990,6 +3029,7 @@ class ApplicationsController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
]
|
||||
)]
|
||||
public function delete_by_uuid(Request $request)
|
||||
@@ -3109,10 +3149,10 @@ class ApplicationsController extends Controller
|
||||
'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'],
|
||||
'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'],
|
||||
'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'],
|
||||
'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'],
|
||||
'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
|
||||
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
|
||||
@@ -3189,6 +3229,10 @@ class ApplicationsController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
@@ -3359,6 +3403,9 @@ class ApplicationsController extends Controller
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request, $application)) {
|
||||
return $forbidden;
|
||||
}
|
||||
|
||||
$githubApp = null;
|
||||
if ($request->has('github_app_uuid')) {
|
||||
@@ -5334,7 +5381,7 @@ class ApplicationsController extends Controller
|
||||
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
|
||||
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional). Needs the deploy ability when set.'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
@@ -5352,7 +5399,7 @@ class ApplicationsController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
@@ -5553,6 +5600,13 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('content')) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
@@ -5664,7 +5718,7 @@ class ApplicationsController extends Controller
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Preview Deployment',
|
||||
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.',
|
||||
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record. Needs a token with the write and deploy permissions.',
|
||||
path: '/applications/{uuid}/previews/{pull_request_id}',
|
||||
operationId: 'delete-preview-deployment-by-pull-request-id',
|
||||
security: [
|
||||
@@ -5695,6 +5749,7 @@ class ApplicationsController extends Controller
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
]
|
||||
)]
|
||||
public function delete_preview_by_pull_request_id(Request $request): JsonResponse
|
||||
@@ -5898,7 +5953,7 @@ class ApplicationsController extends Controller
|
||||
properties: [
|
||||
new OA\Property(property: 'destination_uuid', type: 'string', description: 'UUID of the destination to clone into.'),
|
||||
new OA\Property(property: 'name', type: 'string', nullable: true, description: 'Optional name for the cloned application.'),
|
||||
new OA\Property(property: 'clone_volumes', type: 'boolean', default: false, description: 'Whether to clone volume data.'),
|
||||
new OA\Property(property: 'clone_volumes', type: 'boolean', default: false, description: 'Whether to clone volume data. Needs the deploy ability.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
@@ -5915,6 +5970,7 @@ class ApplicationsController extends Controller
|
||||
),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy ability to clone volume data.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
@@ -5957,6 +6013,10 @@ class ApplicationsController extends Controller
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first()
|
||||
?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first();
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
trait HandlesDatabaseImportsApi
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
protected function uploadDatabaseImport(Request $request, Model $resource, int $teamId): JsonResponse
|
||||
{
|
||||
$this->authorize('uploadBackup', $resource);
|
||||
@@ -82,11 +84,8 @@ trait HandlesDatabaseImportsApi
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
// A server import reads any absolute path on the host, so it needs the same permission as host mounts outside the resource directory.
|
||||
if ($payload['source'] === 'server' && ! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) {
|
||||
return response()->json([
|
||||
'message' => 'Missing required permissions: deploy. An import from a server path needs a token with the deploy permission.',
|
||||
], 403);
|
||||
if ($response = $this->deployAbilityForbiddenResponse($request, 'An import runs the dump in the database container, so it needs a token with the deploy permission.')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* The `write` permission changes what runs on a server, and the `deploy` permission makes it run. A
|
||||
* change that Coolify runs on a server without a deployment needs both, so it needs a token with the
|
||||
* deploy permission (or root).
|
||||
*/
|
||||
trait RequiresDeployAbility
|
||||
{
|
||||
/**
|
||||
* @param string $reason One sentence that tells why the change needs the deploy permission.
|
||||
*/
|
||||
private function deployAbilityForbiddenResponse(Request $request, string $reason): ?JsonResponse
|
||||
{
|
||||
if ($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => "Missing required permissions: deploy. {$reason}",
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,15 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
trait RequiresDeployForOutsideHostPaths
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
private function outsideHostPathForbiddenResponse(Request $request, LocalFileVolume $storage): ?JsonResponse
|
||||
{
|
||||
if ($storage->is_host_file || ! $storage->isOutsideResourceDirectory()) {
|
||||
return null;
|
||||
}
|
||||
if ($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.',
|
||||
], 403);
|
||||
return $this->deployAbilityForbiddenResponse($request, 'A mount outside the resource directory needs a token with the deploy permission.');
|
||||
}
|
||||
|
||||
private function outsideContentChangeForbiddenResponse(Request $request, LocalFileVolume|LocalPersistentVolume $storage): ?JsonResponse
|
||||
|
||||
@@ -40,6 +40,7 @@ class DatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesDatabaseImportsApi;
|
||||
use Concerns\HandlesTagsApi;
|
||||
use Concerns\RequiresDeployAbility;
|
||||
use Concerns\RequiresDeployForOutsideHostPaths;
|
||||
|
||||
#[OA\Post(
|
||||
@@ -71,6 +72,7 @@ class DatabasesController extends Controller
|
||||
path: '/databases/{uuid}/imports',
|
||||
operationId: 'create-database-import',
|
||||
summary: 'Import database backup',
|
||||
description: 'Restore a backup into the database. Every import source requires the deploy ability.',
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')),
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Databases'],
|
||||
@@ -79,6 +81,7 @@ class DatabasesController extends Controller
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 202, description: 'Import queued'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy ability.'),
|
||||
new OA\Response(response: 409, description: 'Another start, restart or import of this database is already in progress.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
@@ -474,8 +477,8 @@ class DatabasesController extends Controller
|
||||
'name' => ['type' => 'string', 'description' => 'Name of the database'],
|
||||
'description' => ['type' => 'string', 'description' => 'Description of the database'],
|
||||
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
|
||||
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
|
||||
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
|
||||
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public? Changing it starts or stops the database proxy, so it needs a token with the deploy permission.'],
|
||||
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database (1024-65535)'],
|
||||
'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
|
||||
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
|
||||
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
|
||||
@@ -535,6 +538,10 @@ class DatabasesController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy permission to change is_public or to use instant_deploy.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
@@ -563,7 +570,7 @@ class DatabasesController extends Controller
|
||||
'description' => 'string|nullable',
|
||||
'image' => 'string',
|
||||
'is_public' => 'boolean',
|
||||
'public_port' => 'numeric|nullable',
|
||||
'public_port' => 'numeric|nullable|between:1024,65535',
|
||||
'public_port_timeout' => 'integer|nullable|min:1',
|
||||
'limits_memory' => 'string',
|
||||
'limits_memory_swap' => 'string',
|
||||
@@ -596,6 +603,13 @@ class DatabasesController extends Controller
|
||||
$this->authorize('manage', $database);
|
||||
}
|
||||
|
||||
if ($request->has('is_public') && (bool) $request->is_public !== (bool) $database->is_public) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->is_public && $request->public_port) {
|
||||
if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) {
|
||||
return response()->json(['message' => 'Public port already used by another database.'], 400);
|
||||
@@ -905,7 +919,7 @@ class DatabasesController extends Controller
|
||||
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'],
|
||||
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
|
||||
'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false],
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation. Needs a token with the deploy permission.'],
|
||||
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
|
||||
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
|
||||
@@ -938,6 +952,10 @@ class DatabasesController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy permission to use backup_now.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
@@ -1000,6 +1018,13 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('manageBackups', $database);
|
||||
|
||||
if ($request->backup_now) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'A backup that runs now copies the database on the server, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $database->isBackupSolutionAvailable()) {
|
||||
return response()->json([
|
||||
'message' => 'Scheduled backups are not supported for this database type.',
|
||||
@@ -1159,7 +1184,7 @@ class DatabasesController extends Controller
|
||||
properties: [
|
||||
'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'],
|
||||
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'],
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'],
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not. Needs a token with the deploy permission.'],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'],
|
||||
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
|
||||
'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'],
|
||||
@@ -1189,6 +1214,10 @@ class DatabasesController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy permission to use backup_now.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
@@ -1254,6 +1283,13 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
if ($request->backup_now) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'A backup that runs now copies the database on the server, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate frequency is a valid cron expression
|
||||
if ($request->filled('frequency')) {
|
||||
$isValid = validate_cron_expression($request->frequency);
|
||||
@@ -2776,7 +2812,7 @@ class DatabasesController extends Controller
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete',
|
||||
description: 'Delete database by UUID.',
|
||||
description: 'Delete database by UUID. Needs a token with the write and deploy permissions.',
|
||||
path: '/databases/{uuid}',
|
||||
operationId: 'delete-database-by-uuid',
|
||||
security: [
|
||||
@@ -2826,6 +2862,7 @@ class DatabasesController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
]
|
||||
)]
|
||||
public function delete_by_uuid(Request $request)
|
||||
@@ -4361,7 +4398,7 @@ class DatabasesController extends Controller
|
||||
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
|
||||
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional). Needs the deploy ability when set.'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
@@ -4379,7 +4416,7 @@ class DatabasesController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
@@ -4576,6 +4613,13 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('content')) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
@@ -5062,7 +5106,7 @@ class DatabasesController extends Controller
|
||||
properties: [
|
||||
new OA\Property(property: 'destination_uuid', type: 'string'),
|
||||
new OA\Property(property: 'name', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'clone_volumes', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'clone_volumes', type: 'boolean', default: false, description: 'Whether to clone volume data. Needs the deploy ability.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
@@ -5070,6 +5114,7 @@ class DatabasesController extends Controller
|
||||
new OA\Response(response: 201, description: 'Database cloned.'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy ability to clone volume data.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
@@ -5112,6 +5157,10 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first()
|
||||
?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first();
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class DestinationsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Docker's built-in networks. A destination must not use them.
|
||||
*/
|
||||
private const RESERVED_NETWORK_NAMES = ['bridge', 'host', 'none'];
|
||||
|
||||
private function transform(StandaloneDocker|SwarmDocker $destination): array
|
||||
{
|
||||
return [
|
||||
@@ -159,7 +164,7 @@ class DestinationsController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create destination',
|
||||
description: 'Create a Docker network destination on a server owned by the authenticated team.',
|
||||
description: 'Create a Docker network destination on a server owned by the authenticated team. The network must be new: Docker\'s built-in networks (bridge, host, none) and networks that already exist on a standalone server are rejected, because Coolify connects the proxy to the destination network.',
|
||||
path: '/servers/{server_uuid}/destinations',
|
||||
operationId: 'create-server-destination',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -173,7 +178,7 @@ class DestinationsController extends Controller
|
||||
required: ['network'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string', maxLength: 255),
|
||||
new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'),
|
||||
new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$', description: 'Name of a new Docker network. Must not be bridge, host, none, or a network that already exists on the server.'),
|
||||
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
|
||||
],
|
||||
type: 'object',
|
||||
@@ -188,7 +193,7 @@ class DestinationsController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'A destination with this network already exists.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
new OA\Response(response: 422, description: 'Validation failed, the network is reserved or already exists on the server, or the server could not be checked.'),
|
||||
],
|
||||
)]
|
||||
public function create(Request $request, string $server_uuid): JsonResponse
|
||||
@@ -224,6 +229,13 @@ class DestinationsController extends Controller
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
if (in_array(strtolower($request->input('network')), self::RESERVED_NETWORK_NAMES, true)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['network' => ['The network name is reserved by Docker. Use a different name.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
|
||||
$type = $request->input('type', $expectedType);
|
||||
if ($type !== $expectedType) {
|
||||
@@ -240,6 +252,11 @@ class DestinationsController extends Controller
|
||||
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
|
||||
}
|
||||
|
||||
// Creating a standalone destination connects the proxy to the network, so it must not be an existing network.
|
||||
if ($class === StandaloneDocker::class && ($response = $this->existingServerNetworkResponse($server, $request->input('network')))) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
$destination = $class::create([
|
||||
'name' => $name,
|
||||
@@ -265,6 +282,27 @@ class DestinationsController extends Controller
|
||||
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
|
||||
}
|
||||
|
||||
private function existingServerNetworkResponse(Server $server, string $network): ?JsonResponse
|
||||
{
|
||||
try {
|
||||
$output = instant_remote_process(["docker network ls --format '{{.Name}}'"], $server);
|
||||
} catch (\Throwable) {
|
||||
return response()->json([
|
||||
'message' => 'Could not read the Docker networks on the server. Check that the server is reachable and try again.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$existingNetworks = array_map('trim', explode("\n", (string) $output));
|
||||
if (in_array($network, $existingNetworks, true)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['network' => ['A Docker network with this name already exists on the server. Use a new network name.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isUniqueConstraintViolation(QueryException $exception): bool
|
||||
{
|
||||
$sqlState = $exception->errorInfo[0] ?? null;
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
@@ -21,6 +22,8 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class DigitalOceanController extends Controller
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||
{
|
||||
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
|
||||
@@ -198,11 +201,13 @@ class DigitalOceanController extends Controller
|
||||
path: '/servers/digitalocean',
|
||||
operationId: 'create-digitalocean-server',
|
||||
summary: 'Create a server on DigitalOcean',
|
||||
description: 'Create a DigitalOcean droplet and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['DigitalOcean'],
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'DigitalOcean droplet created and linked to a Coolify server.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'),
|
||||
]
|
||||
@@ -264,6 +269,13 @@ class DigitalOceanController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! empty($request->cloud_init_script) || ! empty($request->digitalocean_ssh_key_ids)) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
$team = Team::find($teamId);
|
||||
if (Team::serverLimitReached($team)) {
|
||||
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
@@ -20,6 +21,8 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class HetznerController extends Controller
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
/**
|
||||
* Get cloud provider token UUID from request.
|
||||
* Prefers cloud_provider_token_uuid over deprecated cloud_provider_token_id.
|
||||
@@ -652,7 +655,7 @@ class HetznerController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Hetzner Server',
|
||||
description: 'Create a new server on Hetzner and register it in Coolify.',
|
||||
description: 'Create a new server on Hetzner and register it in Coolify. A cloud-init script or extra SSH key IDs need a token with the deploy permission.',
|
||||
path: '/servers/hetzner',
|
||||
operationId: 'create-hetzner-server',
|
||||
security: [
|
||||
@@ -678,10 +681,10 @@ class HetznerController extends Controller
|
||||
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
|
||||
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
|
||||
'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'],
|
||||
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs. Needs a token with the deploy permission.'],
|
||||
'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'],
|
||||
'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'],
|
||||
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional). Needs a token with the deploy permission.'],
|
||||
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||
],
|
||||
),
|
||||
@@ -712,6 +715,7 @@ class HetznerController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
@@ -793,6 +797,13 @@ class HetznerController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! empty($request->cloud_init_script) || ! empty($request->hetzner_ssh_key_ids)) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
// Check server limit
|
||||
$team = Team::find($teamId);
|
||||
if (Team::serverLimitReached($team)) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\Application;
|
||||
@@ -13,6 +14,8 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class ScheduledTasksController extends Controller
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
private function removeSensitiveData($task)
|
||||
{
|
||||
$task->makeHidden([
|
||||
@@ -38,13 +41,11 @@ class ScheduledTasksController extends Controller
|
||||
*/
|
||||
private function enabledTaskForbiddenResponse(Request $request, bool $enabled): ?JsonResponse
|
||||
{
|
||||
if (! $enabled || $request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) {
|
||||
if (! $enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Missing required permissions: deploy. An enabled scheduled task runs commands in the resource containers, so it needs a token with the deploy permission.',
|
||||
], 403);
|
||||
return $this->deployAbilityForbiddenResponse($request, 'An enabled scheduled task runs commands in the resource containers, so it needs a token with the deploy permission.');
|
||||
}
|
||||
|
||||
private function resolveApplication(Request $request, int $teamId): ?Application
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Models\Server;
|
||||
@@ -11,6 +12,8 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerDockerCleanupController extends Controller
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
private const ALLOWED_FIELDS = [
|
||||
'docker_cleanup_frequency',
|
||||
'docker_cleanup_threshold',
|
||||
@@ -89,7 +92,7 @@ class ServerDockerCleanupController extends Controller
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Docker cleanup settings',
|
||||
description: 'Update Docker cleanup settings for a server owned by the authenticated team.',
|
||||
description: 'Update Docker cleanup settings for a server owned by the authenticated team. Turning on `delete_unused_volumes` needs a token with the `deploy` permission.',
|
||||
path: '/servers/{uuid}/docker-cleanup',
|
||||
operationId: 'update-server-docker-cleanup',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -129,6 +132,7 @@ class ServerDockerCleanupController extends Controller
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Turning on `delete_unused_volumes` needs the deploy permission.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
@@ -181,6 +185,11 @@ class ServerDockerCleanupController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$turnsOnVolumeDeletion = $request->boolean('delete_unused_volumes') && ! $server->settings->delete_unused_volumes;
|
||||
if ($turnsOnVolumeDeletion && ($response = $this->deployAbilityForbiddenResponse($request, 'Deleting unused volumes removes data on the server, so it needs a token with the deploy permission.'))) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$settings = $server->settings;
|
||||
foreach (self::ALLOWED_FIELDS as $field) {
|
||||
if ($request->has($field)) {
|
||||
@@ -201,7 +210,7 @@ class ServerDockerCleanupController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Run Docker cleanup',
|
||||
description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team.',
|
||||
description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team. Needs a token with the write and deploy permissions.',
|
||||
path: '/servers/{uuid}/docker-cleanup/run',
|
||||
operationId: 'run-server-docker-cleanup',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -232,6 +241,7 @@ class ServerDockerCleanupController extends Controller
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
|
||||
@@ -125,7 +125,7 @@ class ServerDockerRegistriesController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Log in to a registry on a server',
|
||||
description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login.',
|
||||
description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login. Needs a token with the write and deploy permissions.',
|
||||
path: '/servers/{uuid}/registries',
|
||||
operationId: 'login-server-registry',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -158,6 +158,7 @@ class ServerDockerRegistriesController extends Controller
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
|
||||
@@ -109,7 +109,7 @@ class ServerLogDrainsController extends Controller
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update log drain settings',
|
||||
description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.',
|
||||
description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Needs a token with the write and deploy permissions. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.',
|
||||
path: '/servers/{uuid}/log-drains',
|
||||
operationId: 'update-server-log-drains',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -138,7 +138,7 @@ class ServerLogDrainsController extends Controller
|
||||
new OA\Response(response: 200, description: 'Updated log drain settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Missing the deploy permission for a custom log drain change.'),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
@@ -162,14 +162,6 @@ class ServerLogDrainsController extends Controller
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
// The custom config is applied on the server, like a deployment.
|
||||
$customConfigChanged = collect(['logdrain_custom_config', 'logdrain_custom_config_parser'])
|
||||
->contains(fn (string $field) => $request->has($field)
|
||||
&& (! is_string($request->input($field) ?? '') || (string) $request->input($field) !== (string) $server->settings->{$field}));
|
||||
if ($customConfigChanged && ! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) {
|
||||
return response()->json(['message' => 'Missing required permissions: deploy'], 403);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'is_logdrain_newrelic_enabled' => 'boolean',
|
||||
'logdrain_newrelic_license_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\Server;
|
||||
@@ -15,6 +16,8 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerProxyController extends Controller
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
private function teamIdOrAbort(): int|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
@@ -118,7 +121,7 @@ class ServerProxyController extends Controller
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update server proxy',
|
||||
description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.',
|
||||
description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server. Changing proxy_type needs a token with the `write` and `deploy` permissions.',
|
||||
path: '/servers/{uuid}/proxy',
|
||||
operationId: 'update-server-proxy',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -135,7 +138,7 @@ class ServerProxyController extends Controller
|
||||
new OA\Property(property: 'redirect_url', type: 'string', nullable: true, description: 'Public http(s) redirect URL, or null to clear.'),
|
||||
new OA\Property(property: 'generate_exact_labels', type: 'boolean'),
|
||||
new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.'),
|
||||
new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive).'),
|
||||
new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive). Changing it starts the new proxy on the server and needs the deploy permission.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
@@ -158,6 +161,7 @@ class ServerProxyController extends Controller
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission to change the proxy type.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
@@ -213,6 +217,12 @@ class ServerProxyController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$proxyTypeChanged = $request->has('proxy_type') && filled($request->proxy_type)
|
||||
&& ! str($request->proxy_type)->lower()->exactly(str($server->proxyType())->lower());
|
||||
if ($proxyTypeChanged && ($response = $this->deployAbilityForbiddenResponse($request, 'Changing the proxy type starts the new proxy on the server, so it needs a token with the deploy permission.'))) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$changedFields = array_values(array_intersect($allowedFields, array_keys($request->all())));
|
||||
$redirectChanged = false;
|
||||
|
||||
@@ -242,7 +252,7 @@ class ServerProxyController extends Controller
|
||||
$server->settings->save();
|
||||
}
|
||||
|
||||
if ($request->has('proxy_type') && filled($request->proxy_type)) {
|
||||
if ($proxyTypeChanged) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
$server->refresh();
|
||||
}
|
||||
@@ -322,9 +332,8 @@ class ServerProxyController extends Controller
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
// The proxy compose file is applied on the server, like a deployment.
|
||||
if (! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) {
|
||||
return response()->json(['message' => 'Missing required permissions: deploy'], 403);
|
||||
if ($response = $this->deployAbilityForbiddenResponse($request, 'Coolify applies the proxy configuration on the server, so it needs a token with the deploy permission.')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
|
||||
@@ -130,7 +130,7 @@ class ServerSentinelController extends Controller
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Sentinel settings',
|
||||
description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel.',
|
||||
description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel. Needs a token with the write and deploy permissions.',
|
||||
path: '/servers/{uuid}/sentinel',
|
||||
operationId: 'update-server-sentinel',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -165,6 +165,7 @@ class ServerSentinelController extends Controller
|
||||
new OA\Response(response: 200, description: 'Updated Sentinel settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
|
||||
@@ -184,7 +184,7 @@ class ServerTransferController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Import server transfer bundle',
|
||||
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it.',
|
||||
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it. Needs a token with the write and deploy permissions.',
|
||||
path: '/servers/import',
|
||||
operationId: 'import-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -205,6 +205,7 @@ class ServerTransferController extends Controller
|
||||
new OA\Response(response: 200, description: 'Dry-run result'),
|
||||
new OA\Response(response: 201, description: 'Imported'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed'),
|
||||
]
|
||||
)]
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Enums\ServerRole;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Jobs\ValidateAndInstallServerJob;
|
||||
@@ -27,6 +28,13 @@ use Stringable;
|
||||
|
||||
class ServersController extends Controller
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
/**
|
||||
* The server name goes into one-line config files on the server (for example the log drain config), so it must not contain control characters such as new lines.
|
||||
*/
|
||||
private const SERVER_NAME_CONTROL_CHARACTERS_PATTERN = '/[\x00-\x1F\x7F]/';
|
||||
|
||||
private function removeSensitiveDataFromSettings($settings)
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
@@ -459,7 +467,7 @@ class ServersController extends Controller
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'example' => 'My Server', 'description' => 'The name of the server.'],
|
||||
'name' => ['type' => 'string', 'example' => 'My Server', 'description' => 'The name of the server. Must not contain control characters such as new lines.'],
|
||||
'description' => ['type' => 'string', 'example' => 'My Server Description', 'description' => 'The description of the server.'],
|
||||
'ip' => ['type' => 'string', 'example' => '127.0.0.1', 'description' => 'The IP of the server.'],
|
||||
'port' => ['type' => 'integer', 'example' => 22, 'description' => 'The port of the server.'],
|
||||
@@ -521,7 +529,7 @@ class ServersController extends Controller
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
'name' => ['string', 'max:255', 'not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN],
|
||||
'description' => 'string|nullable',
|
||||
'ip' => ['string', 'required', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
@@ -533,6 +541,7 @@ class ServersController extends Controller
|
||||
'proxy_type' => 'string|nullable',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
'name.not_regex' => 'The name must not contain control characters such as new lines.',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -636,7 +645,7 @@ class ServersController extends Controller
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update',
|
||||
description: 'Update Server.',
|
||||
description: 'Update Server. Changing proxy_type needs a token with the write and deploy permissions.',
|
||||
path: '/servers/{uuid}',
|
||||
operationId: 'update-server-by-uuid',
|
||||
security: [
|
||||
@@ -654,7 +663,7 @@ class ServersController extends Controller
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the server.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the server. Must not contain control characters such as new lines.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The description of the server.'],
|
||||
'ip' => ['type' => 'string', 'description' => 'The IP of the server.'],
|
||||
'port' => ['type' => 'integer', 'description' => 'The port of the server.'],
|
||||
@@ -663,7 +672,7 @@ class ServersController extends Controller
|
||||
'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'description' => 'Server role.'],
|
||||
'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false changes a build server to both and leaves other roles unchanged. Must not conflict with server_role.'],
|
||||
'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'],
|
||||
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'],
|
||||
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type. Changing it starts the new proxy on the server and needs the deploy permission.'],
|
||||
'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'],
|
||||
'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'],
|
||||
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
|
||||
@@ -676,6 +685,7 @@ class ServersController extends Controller
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission to change the proxy type.'),
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Server updated.',
|
||||
@@ -719,7 +729,7 @@ class ServersController extends Controller
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255|nullable',
|
||||
'name' => ['string', 'max:255', 'nullable', 'not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN],
|
||||
'description' => 'string|nullable',
|
||||
'ip' => ['string', 'nullable', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
@@ -739,6 +749,7 @@ class ServersController extends Controller
|
||||
'is_terminal_enabled' => 'boolean|nullable',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
'name.not_regex' => 'The name must not contain control characters such as new lines.',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -822,6 +833,11 @@ class ServersController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$proxyTypeChanged = $request->proxy_type && ! str($request->proxy_type)->lower()->exactly(str($server->proxyType())->lower());
|
||||
if ($proxyTypeChanged && ($response = $this->deployAbilityForbiddenResponse($request, 'Changing the proxy type starts the new proxy on the server, so it needs a token with the deploy permission.'))) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$server->update($updateFields);
|
||||
if ($serverRole !== null) {
|
||||
$server->settings()->update([
|
||||
@@ -841,7 +857,7 @@ class ServersController extends Controller
|
||||
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
|
||||
}
|
||||
|
||||
if ($request->proxy_type) {
|
||||
if ($proxyTypeChanged) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
}
|
||||
|
||||
@@ -863,7 +879,7 @@ class ServersController extends Controller
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete',
|
||||
description: 'Delete server by UUID.',
|
||||
description: 'Delete server by UUID. With force or delete_from_provider, needs a token with the write and deploy permissions.',
|
||||
path: '/servers/{uuid}',
|
||||
operationId: 'delete-server-by-uuid',
|
||||
security: [
|
||||
@@ -883,19 +899,20 @@ class ServersController extends Controller
|
||||
new OA\Parameter(
|
||||
name: 'force',
|
||||
in: 'query',
|
||||
description: 'Also delete all resources on the server.',
|
||||
description: 'Also delete all resources on the server. Needs the deploy permission.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'delete_from_provider',
|
||||
in: 'query',
|
||||
description: 'Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone.',
|
||||
description: 'Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone. Needs the deploy permission.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission to delete the resources or the cloud server.'),
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Server deleted.',
|
||||
@@ -946,6 +963,10 @@ class ServersController extends Controller
|
||||
$this->authorize('delete', $server);
|
||||
|
||||
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
|
||||
$deleteFromProvider = filter_var($request->query('delete_from_provider', false), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if (is_null($deleteFromProvider)) {
|
||||
return response()->json(['message' => 'delete_from_provider must be a boolean.'], 422);
|
||||
}
|
||||
|
||||
if ($server->definedResources()->count() > 0 && ! $force) {
|
||||
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
|
||||
@@ -954,10 +975,6 @@ class ServersController extends Controller
|
||||
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
$deleteFromProvider = filter_var($request->query('delete_from_provider', false), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if (is_null($deleteFromProvider)) {
|
||||
return response()->json(['message' => 'delete_from_provider must be a boolean.'], 422);
|
||||
}
|
||||
if ($deleteFromProvider) {
|
||||
$linkedProviders = array_keys(array_filter([
|
||||
'hetzner' => $server->hetzner_server_id,
|
||||
@@ -974,6 +991,10 @@ class ServersController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (($force || $deleteFromProvider) && ($response = $this->deployAbilityForbiddenResponse($request, 'Deleting the resources or the cloud server needs a token with the deploy permission.'))) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
foreach ($server->definedResources() as $resource) {
|
||||
DeleteResourceJob::dispatch($resource);
|
||||
@@ -1010,7 +1031,7 @@ class ServersController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Validate',
|
||||
description: 'Validate server by UUID.',
|
||||
description: 'Validate server by UUID. With install=true, needs a token with the write and deploy permissions.',
|
||||
path: '/servers/{uuid}/validate',
|
||||
operationId: 'validate-server-by-uuid',
|
||||
security: [
|
||||
@@ -1026,7 +1047,7 @@ class ServersController extends Controller
|
||||
properties: [
|
||||
new OA\Property(
|
||||
property: 'install',
|
||||
description: 'Install missing prerequisites and Docker. This can restart the Docker daemon.',
|
||||
description: 'Install missing prerequisites and Docker, then start the proxy. This can restart the Docker daemon. Needs the deploy permission.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
),
|
||||
@@ -1034,6 +1055,7 @@ class ServersController extends Controller
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission to install the server.'),
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Server validation started.',
|
||||
@@ -1100,6 +1122,9 @@ class ServersController extends Controller
|
||||
}
|
||||
|
||||
$install = $request->boolean('install', false);
|
||||
if ($install && ($response = $this->deployAbilityForbiddenResponse($request, 'Installing the server runs commands on it and starts the proxy, so it needs a token with the deploy permission.'))) {
|
||||
return $response;
|
||||
}
|
||||
if ($install) {
|
||||
ValidateAndInstallServerJob::dispatch($server);
|
||||
} else {
|
||||
|
||||
@@ -44,6 +44,7 @@ class ServiceDatabasesController extends Controller
|
||||
path: '/services/{uuid}/databases/{database_uuid}/imports',
|
||||
operationId: 'create-service-database-import',
|
||||
summary: 'Import service database backup',
|
||||
description: 'Restore a backup into the service database. Every import source requires the deploy ability.',
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')),
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
@@ -53,6 +54,7 @@ class ServiceDatabasesController extends Controller
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 202, description: 'Import queued'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy ability.'),
|
||||
new OA\Response(response: 409, description: 'Another start, restart or import of this database is already in progress.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
@@ -234,7 +236,7 @@ class ServiceDatabasesController extends Controller
|
||||
new OA\Property(property: 'image', type: 'string'),
|
||||
new OA\Property(property: 'exclude_from_status', type: 'boolean'),
|
||||
new OA\Property(property: 'is_log_drain_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_public', type: 'boolean'),
|
||||
new OA\Property(property: 'is_public', type: 'boolean', description: 'Changing it starts or stops the database proxy, so it needs a token with the deploy permission.'),
|
||||
new OA\Property(property: 'public_port', type: 'integer', nullable: true, minimum: 1, maximum: 65535),
|
||||
new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, minimum: 1),
|
||||
],
|
||||
@@ -245,6 +247,7 @@ class ServiceDatabasesController extends Controller
|
||||
new OA\Response(response: 200, description: 'Updated service database.', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission to change is_public.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
@@ -312,6 +315,13 @@ class ServiceDatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_public', $payload) && (bool) $payload['is_public'] !== (bool) $serviceDatabase->is_public) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
$server = $serviceDatabase->service->destination->server;
|
||||
if (($payload['is_log_drain_enabled'] ?? false) && ! $server->isLogDrainEnabled()) {
|
||||
return response()->json([
|
||||
|
||||
@@ -30,6 +30,7 @@ use OpenApi\Attributes as OA;
|
||||
class ServicesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
use Concerns\RequiresDeployAbility;
|
||||
use Concerns\RequiresDeployForOutsideHostPaths;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
@@ -997,7 +998,7 @@ class ServicesController extends Controller
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete',
|
||||
description: 'Delete service by UUID.',
|
||||
description: 'Delete service by UUID. Needs a token with the write and deploy permissions.',
|
||||
path: '/services/{uuid}',
|
||||
operationId: 'delete-service-by-uuid',
|
||||
security: [
|
||||
@@ -1040,6 +1041,7 @@ class ServicesController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
]
|
||||
)]
|
||||
public function delete_by_uuid(Request $request)
|
||||
@@ -2477,7 +2479,7 @@ class ServicesController extends Controller
|
||||
'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'],
|
||||
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional). Needs the deploy ability when set.'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
@@ -2495,7 +2497,7 @@ class ServicesController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
@@ -2701,6 +2703,13 @@ class ServicesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('content')) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
@@ -3238,7 +3247,7 @@ class ServicesController extends Controller
|
||||
properties: [
|
||||
new OA\Property(property: 'destination_uuid', type: 'string'),
|
||||
new OA\Property(property: 'name', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'clone_volumes', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'clone_volumes', type: 'boolean', default: false, description: 'Whether to clone volume data. Needs the deploy ability.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
@@ -3246,6 +3255,7 @@ class ServicesController extends Controller
|
||||
new OA\Response(response: 201, description: 'Service cloned.'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy ability to clone volume data.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
@@ -3288,6 +3298,10 @@ class ServicesController extends Controller
|
||||
|
||||
$this->authorize('update', $service);
|
||||
|
||||
if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first()
|
||||
?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Shared\DeleteScheduledVolumeBackup;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\VolumeBackupJob;
|
||||
use App\Models\Application;
|
||||
@@ -26,7 +27,7 @@ use RuntimeException;
|
||||
new OA\Property(property: 'enabled', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'save_s3', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'disable_local_backup', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false, description: 'Stop the resource containers during the backup. Needs a token with the deploy permission.'),
|
||||
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'retention_amount_locally', type: 'integer', default: 7, minimum: 0, maximum: 10000),
|
||||
new OA\Property(property: 'retention_days_locally', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
|
||||
@@ -67,9 +68,11 @@ use RuntimeException;
|
||||
)]
|
||||
class VolumeBackupsController extends Controller
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
#[OA\Put(
|
||||
summary: 'Set application storage backup schedule',
|
||||
description: 'Create or replace the backup schedule for an application persistent volume or directory storage.',
|
||||
description: 'Create or replace the backup schedule for an application persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.',
|
||||
path: '/applications/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'set-application-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -84,14 +87,14 @@ class VolumeBackupsController extends Controller
|
||||
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 403, description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
#[OA\Put(
|
||||
summary: 'Set database storage backup schedule',
|
||||
description: 'Create or replace the backup schedule for a database persistent volume or directory storage.',
|
||||
description: 'Create or replace the backup schedule for a database persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.',
|
||||
path: '/databases/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'set-database-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -106,14 +109,14 @@ class VolumeBackupsController extends Controller
|
||||
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 403, description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
#[OA\Put(
|
||||
summary: 'Set service storage backup schedule',
|
||||
description: 'Create or replace the backup schedule for a service persistent volume or directory storage.',
|
||||
description: 'Create or replace the backup schedule for a service persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.',
|
||||
path: '/services/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'set-service-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -128,7 +131,7 @@ class VolumeBackupsController extends Controller
|
||||
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 403, description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
@@ -160,6 +163,13 @@ class VolumeBackupsController extends Controller
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
if ($request->boolean('stop_during_backup')) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'A backup that stops the containers needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
$storage = $this->findStorage($resource, $request->route('storage_uuid'));
|
||||
if (! $storage) {
|
||||
return response()->json(['message' => 'Storage not found.'], 404);
|
||||
@@ -458,7 +468,7 @@ class VolumeBackupsController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Run application storage backup',
|
||||
description: 'Queue an immediate volume backup for an application storage that has a schedule.',
|
||||
description: 'Queue an immediate volume backup for an application storage that has a schedule. Needs a token with the write and deploy permissions.',
|
||||
path: '/applications/{uuid}/storages/{storage_uuid}/backups/run',
|
||||
operationId: 'run-application-storage-backup',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -470,12 +480,13 @@ class VolumeBackupsController extends Controller
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Storage backup queued.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
#[OA\Post(
|
||||
summary: 'Run database storage backup',
|
||||
description: 'Queue an immediate volume backup for a database storage that has a schedule.',
|
||||
description: 'Queue an immediate volume backup for a database storage that has a schedule. Needs a token with the write and deploy permissions.',
|
||||
path: '/databases/{uuid}/storages/{storage_uuid}/backups/run',
|
||||
operationId: 'run-database-storage-backup',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -487,12 +498,13 @@ class VolumeBackupsController extends Controller
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Storage backup queued.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
#[OA\Post(
|
||||
summary: 'Run service storage backup',
|
||||
description: 'Queue an immediate volume backup for a service storage that has a schedule.',
|
||||
description: 'Queue an immediate volume backup for a service storage that has a schedule. Needs a token with the write and deploy permissions.',
|
||||
path: '/services/{uuid}/storages/{storage_uuid}/backups/run',
|
||||
operationId: 'run-service-storage-backup',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -504,6 +516,7 @@ class VolumeBackupsController extends Controller
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Storage backup queued.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
@@ -21,6 +22,8 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class VultrController extends Controller
|
||||
{
|
||||
use RequiresDeployAbility;
|
||||
|
||||
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||
{
|
||||
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
|
||||
@@ -177,7 +180,7 @@ class VultrController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Vultr Server',
|
||||
description: 'Create a Vultr instance and link it as a Coolify server.',
|
||||
description: 'Create a Vultr instance and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.',
|
||||
path: '/servers/vultr',
|
||||
operationId: 'create-vultr-server',
|
||||
security: [
|
||||
@@ -187,6 +190,7 @@ class VultrController extends Controller
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Vultr server created.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'),
|
||||
]
|
||||
@@ -247,6 +251,13 @@ class VultrController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! empty($request->cloud_init_script) || ! empty($request->vultr_ssh_key_ids)) {
|
||||
$forbidden = $this->deployAbilityForbiddenResponse($request, 'A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.');
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
$team = Team::find($teamId);
|
||||
if (Team::serverLimitReached($team)) {
|
||||
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||
|
||||
@@ -103,8 +103,6 @@ class ApiTokens extends Component
|
||||
$this->permissions = ['root'];
|
||||
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
|
||||
$this->permissions[] = 'read';
|
||||
} elseif ($permissionToUpdate == 'deploy' && in_array('deploy', $this->permissions, true)) {
|
||||
$this->permissions = ['deploy'];
|
||||
} else {
|
||||
if (count($this->permissions) == 0) {
|
||||
$this->permissions = ['read'];
|
||||
|
||||
@@ -107,6 +107,11 @@ class LocalFileVolume extends BaseModel
|
||||
if ($fileVolume->is_host_file) {
|
||||
return;
|
||||
}
|
||||
// A token with only the write permission can save a Compose file. A mount outside the resource
|
||||
// directory is written when the resource is deployed or started, not when it is saved.
|
||||
if ($fileVolume->isOutsideResourceDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ServerStorageSaveJob::dispatch($fileVolume)->afterCommit();
|
||||
});
|
||||
|
||||
@@ -210,6 +210,9 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
|
||||
$fsPath = trim((string) $fileVolume->fs_path);
|
||||
$fileLocation = str_starts_with($fsPath, '$') ? $fsPath : $fileVolume->resolvedFsPath($workdir)->value();
|
||||
$escapedFileLocation = filesystemVolumeShellArgument((string) $fileLocation);
|
||||
// A token with only the write permission can save a Compose file, so Coolify does not
|
||||
// write outside the resource directory here. A deployment or start writes those paths.
|
||||
$isOutsideResourceDirectory = fn (): bool => $fileVolume->isOutsideResourceDirectory();
|
||||
// Exists and is a file
|
||||
$isFile = instant_remote_process(["test -f {$escapedFileLocation} && echo OK || echo NOK"], $server);
|
||||
// Exists and is a directory
|
||||
@@ -225,7 +228,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
|
||||
// A configured file must not lose its content because a directory is at its path.
|
||||
// Docker leaves an empty directory when it starts before the file exists: replace it.
|
||||
// A directory with files stays; the start or deployment shows a warning for it.
|
||||
if (LocalFileVolume::remoteFileStates([(string) $fileLocation], $server)[0] === 'empty-directory') {
|
||||
if (! $isOutsideResourceDirectory() && LocalFileVolume::remoteFileStates([(string) $fileLocation], $server)[0] === 'empty-directory') {
|
||||
$fileVolume->saveStorageOnServer();
|
||||
}
|
||||
} elseif ($isDir === 'OK') {
|
||||
@@ -235,6 +238,9 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
|
||||
$fileVolume->save();
|
||||
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && $content) {
|
||||
// Does not exists (no dir or file), not flagged as directory, is init, has content
|
||||
if ($isOutsideResourceDirectory()) {
|
||||
continue;
|
||||
}
|
||||
$fileVolume->content = $content;
|
||||
$fileVolume->is_directory = false;
|
||||
$fileVolume->save();
|
||||
@@ -248,13 +254,17 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
|
||||
$fileVolume->content = null;
|
||||
$fileVolume->is_directory = true;
|
||||
$fileVolume->save();
|
||||
instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server);
|
||||
if (! $isOutsideResourceDirectory()) {
|
||||
instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server);
|
||||
}
|
||||
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && is_null($content)) {
|
||||
// Does not exists (no dir or file), not flagged as directory, is init, has no content => create directory
|
||||
$fileVolume->content = null;
|
||||
$fileVolume->is_directory = true;
|
||||
$fileVolume->save();
|
||||
instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server);
|
||||
if (! $isOutsideResourceDirectory()) {
|
||||
instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
|
||||
+178
-71
@@ -244,7 +244,7 @@
|
||||
"Applications"
|
||||
],
|
||||
"summary": "Delete Preview Deployment",
|
||||
"description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.",
|
||||
"description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "delete-preview-deployment-by-pull-request-id",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -293,6 +293,9 @@
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -769,19 +772,19 @@
|
||||
},
|
||||
"manual_webhook_secret_github": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Github."
|
||||
"description": "Manual webhook secret for Github. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitlab": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitlab."
|
||||
"description": "Manual webhook secret for Gitlab. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_bitbucket": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Bitbucket."
|
||||
"description": "Manual webhook secret for Bitbucket. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitea": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitea."
|
||||
"description": "Manual webhook secret for Gitea. Needs the deploy ability."
|
||||
},
|
||||
"redirect": {
|
||||
"type": "string",
|
||||
@@ -1040,6 +1043,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability to set a manual webhook secret."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
@@ -1365,19 +1371,19 @@
|
||||
},
|
||||
"manual_webhook_secret_github": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Github."
|
||||
"description": "Manual webhook secret for Github. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitlab": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitlab."
|
||||
"description": "Manual webhook secret for Gitlab. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_bitbucket": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Bitbucket."
|
||||
"description": "Manual webhook secret for Bitbucket. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitea": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitea."
|
||||
"description": "Manual webhook secret for Gitea. Needs the deploy ability."
|
||||
},
|
||||
"redirect": {
|
||||
"type": "string",
|
||||
@@ -1636,6 +1642,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability to set a manual webhook secret."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
@@ -1961,19 +1970,19 @@
|
||||
},
|
||||
"manual_webhook_secret_github": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Github."
|
||||
"description": "Manual webhook secret for Github. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitlab": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitlab."
|
||||
"description": "Manual webhook secret for Gitlab. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_bitbucket": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Bitbucket."
|
||||
"description": "Manual webhook secret for Bitbucket. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitea": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitea."
|
||||
"description": "Manual webhook secret for Gitea. Needs the deploy ability."
|
||||
},
|
||||
"redirect": {
|
||||
"type": "string",
|
||||
@@ -2232,6 +2241,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability to set a manual webhook secret."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
@@ -2495,19 +2507,19 @@
|
||||
},
|
||||
"manual_webhook_secret_github": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Github."
|
||||
"description": "Manual webhook secret for Github. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitlab": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitlab."
|
||||
"description": "Manual webhook secret for Gitlab. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_bitbucket": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Bitbucket."
|
||||
"description": "Manual webhook secret for Bitbucket. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitea": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitea."
|
||||
"description": "Manual webhook secret for Gitea. Needs the deploy ability."
|
||||
},
|
||||
"redirect": {
|
||||
"type": "string",
|
||||
@@ -2718,6 +2730,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability to set a manual webhook secret."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
@@ -2966,19 +2981,19 @@
|
||||
},
|
||||
"manual_webhook_secret_github": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Github."
|
||||
"description": "Manual webhook secret for Github. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitlab": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitlab."
|
||||
"description": "Manual webhook secret for Gitlab. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_bitbucket": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Bitbucket."
|
||||
"description": "Manual webhook secret for Bitbucket. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitea": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitea."
|
||||
"description": "Manual webhook secret for Gitea. Needs the deploy ability."
|
||||
},
|
||||
"redirect": {
|
||||
"type": "string",
|
||||
@@ -3189,6 +3204,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability to set a manual webhook secret."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
@@ -3305,7 +3323,7 @@
|
||||
"Applications"
|
||||
],
|
||||
"summary": "Delete",
|
||||
"description": "Delete application by UUID.",
|
||||
"description": "Delete application by UUID. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "delete-application-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -3383,6 +3401,9 @@
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -3624,19 +3645,19 @@
|
||||
},
|
||||
"manual_webhook_secret_github": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Github."
|
||||
"description": "Manual webhook secret for Github. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitlab": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitlab."
|
||||
"description": "Manual webhook secret for Gitlab. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_bitbucket": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Bitbucket."
|
||||
"description": "Manual webhook secret for Bitbucket. Needs the deploy ability."
|
||||
},
|
||||
"manual_webhook_secret_gitea": {
|
||||
"type": "string",
|
||||
"description": "Manual webhook secret for Gitea."
|
||||
"description": "Manual webhook secret for Gitea. Needs the deploy ability."
|
||||
},
|
||||
"redirect": {
|
||||
"type": "string",
|
||||
@@ -3867,6 +3888,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability to set a manual webhook secret."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
@@ -4976,7 +5000,7 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "File content (file only, optional)."
|
||||
"description": "File content (file only, optional). Needs the deploy ability when set."
|
||||
},
|
||||
"is_directory": {
|
||||
"type": "boolean",
|
||||
@@ -5014,7 +5038,7 @@
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission for a mount outside the resource directory."
|
||||
"description": "The token needs the deploy permission for a mount outside the resource directory or for a file storage with content."
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
@@ -5405,7 +5429,7 @@
|
||||
]
|
||||
},
|
||||
"clone_volumes": {
|
||||
"description": "Whether to clone volume data.",
|
||||
"description": "Whether to clone volume data. Needs the deploy ability.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
@@ -5441,6 +5465,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability to clone volume data."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -6549,6 +6576,7 @@
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Import database backup",
|
||||
"description": "Restore a backup into the database. Every import source requires the deploy ability.",
|
||||
"operationId": "create-database-import",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -6575,6 +6603,9 @@
|
||||
"202": {
|
||||
"description": "Import queued"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability."
|
||||
},
|
||||
"409": {
|
||||
"description": "Another start, restart or import of this database is already in progress."
|
||||
},
|
||||
@@ -6776,7 +6807,7 @@
|
||||
},
|
||||
"backup_now": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to trigger backup immediately after creation"
|
||||
"description": "Whether to trigger backup immediately after creation. Needs a token with the deploy permission."
|
||||
},
|
||||
"database_backup_retention_amount_locally": {
|
||||
"type": "integer",
|
||||
@@ -6848,6 +6879,9 @@
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission to use backup_now."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -6914,7 +6948,7 @@
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Delete",
|
||||
"description": "Delete database by UUID.",
|
||||
"description": "Delete database by UUID. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "delete-database-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -6992,6 +7026,9 @@
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -7039,11 +7076,11 @@
|
||||
},
|
||||
"is_public": {
|
||||
"type": "boolean",
|
||||
"description": "Is the database public?"
|
||||
"description": "Is the database public? Changing it starts or stops the database proxy, so it needs a token with the deploy permission."
|
||||
},
|
||||
"public_port": {
|
||||
"type": "integer",
|
||||
"description": "Public port of the database"
|
||||
"description": "Public port of the database (1024-65535)"
|
||||
},
|
||||
"public_port_timeout": {
|
||||
"type": "integer",
|
||||
@@ -7238,6 +7275,9 @@
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission to change is_public or to use instant_deploy."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -7374,7 +7414,7 @@
|
||||
},
|
||||
"backup_now": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to take a backup now or not"
|
||||
"description": "Whether to take a backup now or not. Needs a token with the deploy permission."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
@@ -7443,6 +7483,9 @@
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission to use backup_now."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -9800,7 +9843,7 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "File content (file only, optional)."
|
||||
"description": "File content (file only, optional). Needs the deploy ability when set."
|
||||
},
|
||||
"is_directory": {
|
||||
"type": "boolean",
|
||||
@@ -9838,7 +9881,7 @@
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission for a mount outside the resource directory."
|
||||
"description": "The token needs the deploy permission for a mount outside the resource directory or for a file storage with content."
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
@@ -10227,6 +10270,7 @@
|
||||
]
|
||||
},
|
||||
"clone_volumes": {
|
||||
"description": "Whether to clone volume data. Needs the deploy ability.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
@@ -10246,6 +10290,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability to clone volume data."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -10690,7 +10737,7 @@
|
||||
"Destinations"
|
||||
],
|
||||
"summary": "Create destination",
|
||||
"description": "Create a Docker network destination on a server owned by the authenticated team.",
|
||||
"description": "Create a Docker network destination on a server owned by the authenticated team. The network must be new: Docker's built-in networks (bridge, host, none) and networks that already exist on a standalone server are rejected, because Coolify connects the proxy to the destination network.",
|
||||
"operationId": "create-server-destination",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -10717,6 +10764,7 @@
|
||||
"maxLength": 255
|
||||
},
|
||||
"network": {
|
||||
"description": "Name of a new Docker network. Must not be bridge, host, none, or a network that already exists on the server.",
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"
|
||||
@@ -10755,7 +10803,7 @@
|
||||
"description": "A destination with this network already exists."
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
"description": "Validation failed, the network is reserved or already exists on the server, or the server could not be checked."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -11103,6 +11151,7 @@
|
||||
"DigitalOcean"
|
||||
],
|
||||
"summary": "Create a server on DigitalOcean",
|
||||
"description": "Create a DigitalOcean droplet and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.",
|
||||
"operationId": "create-digitalocean-server",
|
||||
"responses": {
|
||||
"201": {
|
||||
@@ -11111,6 +11160,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission for a cloud-init script or extra SSH keys."
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation failed."
|
||||
},
|
||||
@@ -12564,7 +12616,7 @@
|
||||
"Hetzner"
|
||||
],
|
||||
"summary": "Create Hetzner Server",
|
||||
"description": "Create a new server on Hetzner and register it in Coolify.",
|
||||
"description": "Create a new server on Hetzner and register it in Coolify. A cloud-init script or extra SSH key IDs need a token with the deploy permission.",
|
||||
"operationId": "create-hetzner-server",
|
||||
"requestBody": {
|
||||
"description": "Hetzner server creation parameters",
|
||||
@@ -12635,7 +12687,7 @@
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Additional Hetzner SSH key IDs"
|
||||
"description": "Additional Hetzner SSH key IDs. Needs a token with the deploy permission."
|
||||
},
|
||||
"hetzner_firewall_ids": {
|
||||
"type": "array",
|
||||
@@ -12653,7 +12705,7 @@
|
||||
},
|
||||
"cloud_init_script": {
|
||||
"type": "string",
|
||||
"description": "Cloud-init YAML script (optional)"
|
||||
"description": "Cloud-init YAML script (optional). Needs a token with the deploy permission."
|
||||
},
|
||||
"instant_validate": {
|
||||
"type": "boolean",
|
||||
@@ -12698,6 +12750,9 @@
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission for a cloud-init script or extra SSH keys."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -15913,7 +15968,7 @@
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Update Docker cleanup settings",
|
||||
"description": "Update Docker cleanup settings for a server owned by the authenticated team.",
|
||||
"description": "Update Docker cleanup settings for a server owned by the authenticated team. Turning on `delete_unused_volumes` needs a token with the `deploy` permission.",
|
||||
"operationId": "update-server-docker-cleanup",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -15996,6 +16051,9 @@
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "Turning on `delete_unused_volumes` needs the deploy permission."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -16016,7 +16074,7 @@
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Run Docker cleanup",
|
||||
"description": "Dispatch a manual Docker cleanup job for a server owned by the authenticated team.",
|
||||
"description": "Dispatch a manual Docker cleanup job for a server owned by the authenticated team. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "run-server-docker-cleanup",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -16070,6 +16128,9 @@
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -16275,7 +16336,7 @@
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Log in to a registry on a server",
|
||||
"description": "Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login.",
|
||||
"description": "Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "login-server-registry",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -16341,6 +16402,9 @@
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -16575,7 +16639,7 @@
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Update log drain settings",
|
||||
"description": "Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.",
|
||||
"description": "Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Needs a token with the write and deploy permissions. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.",
|
||||
"operationId": "update-server-log-drains",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -16638,7 +16702,7 @@
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "Missing the deploy permission for a custom log drain change."
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
@@ -16751,7 +16815,7 @@
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Update server proxy",
|
||||
"description": "Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.",
|
||||
"description": "Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server. Changing proxy_type needs a token with the `write` and `deploy` permissions.",
|
||||
"operationId": "update-server-proxy",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -16791,7 +16855,7 @@
|
||||
]
|
||||
},
|
||||
"proxy_type": {
|
||||
"description": "Proxy type (case-insensitive).",
|
||||
"description": "Proxy type (case-insensitive). Changing it starts the new proxy on the server and needs the deploy permission.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"traefik",
|
||||
@@ -16861,6 +16925,9 @@
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission to change the proxy type."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -17151,7 +17218,7 @@
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Update Sentinel settings",
|
||||
"description": "Update Sentinel settings for a server owned by the authenticated team. Changing token\/metrics timing fields may restart Sentinel.",
|
||||
"description": "Update Sentinel settings for a server owned by the authenticated team. Changing token\/metrics timing fields may restart Sentinel. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "update-server-sentinel",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -17254,6 +17321,9 @@
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -17320,7 +17390,7 @@
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "My Server",
|
||||
"description": "The name of the server."
|
||||
"description": "The name of the server. Must not contain control characters such as new lines."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
@@ -17472,7 +17542,7 @@
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Delete",
|
||||
"description": "Delete server by UUID.",
|
||||
"description": "Delete server by UUID. With force or delete_from_provider, needs a token with the write and deploy permissions.",
|
||||
"operationId": "delete-server-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -17487,7 +17557,7 @@
|
||||
{
|
||||
"name": "force",
|
||||
"in": "query",
|
||||
"description": "Also delete all resources on the server.",
|
||||
"description": "Also delete all resources on the server. Needs the deploy permission.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
@@ -17497,7 +17567,7 @@
|
||||
{
|
||||
"name": "delete_from_provider",
|
||||
"in": "query",
|
||||
"description": "Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone.",
|
||||
"description": "Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone. Needs the deploy permission.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
@@ -17506,6 +17576,9 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission to delete the resources or the cloud server."
|
||||
},
|
||||
"200": {
|
||||
"description": "Server deleted.",
|
||||
"content": {
|
||||
@@ -17546,7 +17619,7 @@
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Update",
|
||||
"description": "Update Server.",
|
||||
"description": "Update Server. Changing proxy_type needs a token with the write and deploy permissions.",
|
||||
"operationId": "update-server-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -17568,7 +17641,7 @@
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the server."
|
||||
"description": "The name of the server. Must not contain control characters such as new lines."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
@@ -17615,7 +17688,7 @@
|
||||
"caddy",
|
||||
"none"
|
||||
],
|
||||
"description": "The proxy type."
|
||||
"description": "The proxy type. Changing it starts the new proxy on the server and needs the deploy permission."
|
||||
},
|
||||
"concurrent_builds": {
|
||||
"type": "integer",
|
||||
@@ -17652,6 +17725,9 @@
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission to change the proxy type."
|
||||
},
|
||||
"201": {
|
||||
"description": "Server updated.",
|
||||
"content": {
|
||||
@@ -17816,7 +17892,7 @@
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Validate",
|
||||
"description": "Validate server by UUID.",
|
||||
"description": "Validate server by UUID. With install=true, needs a token with the write and deploy permissions.",
|
||||
"operationId": "validate-server-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -17836,7 +17912,7 @@
|
||||
"schema": {
|
||||
"properties": {
|
||||
"install": {
|
||||
"description": "Install missing prerequisites and Docker. This can restart the Docker daemon.",
|
||||
"description": "Install missing prerequisites and Docker, then start the proxy. This can restart the Docker daemon. Needs the deploy permission.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
@@ -17847,6 +17923,9 @@
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission to install the server."
|
||||
},
|
||||
"201": {
|
||||
"description": "Server validation started.",
|
||||
"content": {
|
||||
@@ -18563,6 +18642,7 @@
|
||||
"Service databases"
|
||||
],
|
||||
"summary": "Import service database backup",
|
||||
"description": "Restore a backup into the service database. Every import source requires the deploy ability.",
|
||||
"operationId": "create-service-database-import",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -18598,6 +18678,9 @@
|
||||
"202": {
|
||||
"description": "Import queued"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability."
|
||||
},
|
||||
"409": {
|
||||
"description": "Another start, restart or import of this database is already in progress."
|
||||
},
|
||||
@@ -18823,6 +18906,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_public": {
|
||||
"description": "Changing it starts or stops the database proxy, so it needs a token with the deploy permission.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"public_port": {
|
||||
@@ -18864,6 +18948,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission to change is_public."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -19455,7 +19542,7 @@
|
||||
"Services"
|
||||
],
|
||||
"summary": "Delete",
|
||||
"description": "Delete service by UUID.",
|
||||
"description": "Delete service by UUID. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "delete-service-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -19543,6 +19630,9 @@
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -20658,7 +20748,7 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "File content (file only, optional)."
|
||||
"description": "File content (file only, optional). Needs the deploy ability when set."
|
||||
},
|
||||
"is_directory": {
|
||||
"type": "boolean",
|
||||
@@ -20696,7 +20786,7 @@
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission for a mount outside the resource directory."
|
||||
"description": "The token needs the deploy permission for a mount outside the resource directory or for a file storage with content."
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
@@ -21085,6 +21175,7 @@
|
||||
]
|
||||
},
|
||||
"clone_volumes": {
|
||||
"description": "Whether to clone volume data. Needs the deploy ability.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
@@ -21104,6 +21195,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy ability to clone volume data."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
@@ -22310,7 +22404,7 @@
|
||||
"Applications"
|
||||
],
|
||||
"summary": "Set application storage backup schedule",
|
||||
"description": "Create or replace the backup schedule for an application persistent volume or directory storage.",
|
||||
"description": "Create or replace the backup schedule for an application persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.",
|
||||
"operationId": "set-application-storage-backup-schedule",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -22370,7 +22464,7 @@
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden."
|
||||
"description": "Forbidden, or the token needs the deploy permission for stop_during_backup."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
@@ -22440,7 +22534,7 @@
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Set database storage backup schedule",
|
||||
"description": "Create or replace the backup schedule for a database persistent volume or directory storage.",
|
||||
"description": "Create or replace the backup schedule for a database persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.",
|
||||
"operationId": "set-database-storage-backup-schedule",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -22500,7 +22594,7 @@
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden."
|
||||
"description": "Forbidden, or the token needs the deploy permission for stop_during_backup."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
@@ -22570,7 +22664,7 @@
|
||||
"Services"
|
||||
],
|
||||
"summary": "Set service storage backup schedule",
|
||||
"description": "Create or replace the backup schedule for a service persistent volume or directory storage.",
|
||||
"description": "Create or replace the backup schedule for a service persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.",
|
||||
"operationId": "set-service-storage-backup-schedule",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -22630,7 +22724,7 @@
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden."
|
||||
"description": "Forbidden, or the token needs the deploy permission for stop_during_backup."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
@@ -22700,7 +22794,7 @@
|
||||
"Applications"
|
||||
],
|
||||
"summary": "Run application storage backup",
|
||||
"description": "Queue an immediate volume backup for an application storage that has a schedule.",
|
||||
"description": "Queue an immediate volume backup for an application storage that has a schedule. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "run-application-storage-backup",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -22727,6 +22821,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
@@ -22744,7 +22841,7 @@
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Run database storage backup",
|
||||
"description": "Queue an immediate volume backup for a database storage that has a schedule.",
|
||||
"description": "Queue an immediate volume backup for a database storage that has a schedule. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "run-database-storage-backup",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -22771,6 +22868,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
@@ -22788,7 +22888,7 @@
|
||||
"Services"
|
||||
],
|
||||
"summary": "Run service storage backup",
|
||||
"description": "Queue an immediate volume backup for a service storage that has a schedule.",
|
||||
"description": "Queue an immediate volume backup for a service storage that has a schedule. Needs a token with the write and deploy permissions.",
|
||||
"operationId": "run-service-storage-backup",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -22815,6 +22915,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the write and deploy permissions."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
@@ -22936,7 +23039,7 @@
|
||||
"Vultr"
|
||||
],
|
||||
"summary": "Create Vultr Server",
|
||||
"description": "Create a Vultr instance and link it as a Coolify server.",
|
||||
"description": "Create a Vultr instance and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.",
|
||||
"operationId": "create-vultr-server",
|
||||
"responses": {
|
||||
"201": {
|
||||
@@ -22945,6 +23048,9 @@
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "The token needs the deploy permission for a cloud-init script or extra SSH keys."
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation failed."
|
||||
},
|
||||
@@ -23268,6 +23374,7 @@
|
||||
"default": false
|
||||
},
|
||||
"stop_during_backup": {
|
||||
"description": "Stop the resource containers during the backup. Needs a token with the deploy permission.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
|
||||
+145
-71
@@ -155,7 +155,7 @@ paths:
|
||||
tags:
|
||||
- Applications
|
||||
summary: 'Delete Preview Deployment'
|
||||
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.'
|
||||
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record. Needs a token with the write and deploy permissions.'
|
||||
operationId: delete-preview-deployment-by-pull-request-id
|
||||
parameters:
|
||||
-
|
||||
@@ -189,6 +189,8 @@ paths:
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -503,16 +505,16 @@ paths:
|
||||
description: 'Pre deployment command container.'
|
||||
manual_webhook_secret_github:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Github.'
|
||||
description: 'Manual webhook secret for Github. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitlab:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitlab.'
|
||||
description: 'Manual webhook secret for Gitlab. Needs the deploy ability.'
|
||||
manual_webhook_secret_bitbucket:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Bitbucket.'
|
||||
description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitea:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitea.'
|
||||
description: 'Manual webhook secret for Gitea. Needs the deploy ability.'
|
||||
redirect:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -684,6 +686,8 @@ paths:
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'409':
|
||||
@@ -893,16 +897,16 @@ paths:
|
||||
description: 'Pre deployment command container.'
|
||||
manual_webhook_secret_github:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Github.'
|
||||
description: 'Manual webhook secret for Github. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitlab:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitlab.'
|
||||
description: 'Manual webhook secret for Gitlab. Needs the deploy ability.'
|
||||
manual_webhook_secret_bitbucket:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Bitbucket.'
|
||||
description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitea:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitea.'
|
||||
description: 'Manual webhook secret for Gitea. Needs the deploy ability.'
|
||||
redirect:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -1074,6 +1078,8 @@ paths:
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'409':
|
||||
@@ -1283,16 +1289,16 @@ paths:
|
||||
description: 'Pre deployment command container.'
|
||||
manual_webhook_secret_github:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Github.'
|
||||
description: 'Manual webhook secret for Github. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitlab:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitlab.'
|
||||
description: 'Manual webhook secret for Gitlab. Needs the deploy ability.'
|
||||
manual_webhook_secret_bitbucket:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Bitbucket.'
|
||||
description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitea:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitea.'
|
||||
description: 'Manual webhook secret for Gitea. Needs the deploy ability.'
|
||||
redirect:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -1464,6 +1470,8 @@ paths:
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'409':
|
||||
@@ -1630,16 +1638,16 @@ paths:
|
||||
description: 'Pre deployment command container.'
|
||||
manual_webhook_secret_github:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Github.'
|
||||
description: 'Manual webhook secret for Github. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitlab:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitlab.'
|
||||
description: 'Manual webhook secret for Gitlab. Needs the deploy ability.'
|
||||
manual_webhook_secret_bitbucket:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Bitbucket.'
|
||||
description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitea:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitea.'
|
||||
description: 'Manual webhook secret for Gitea. Needs the deploy ability.'
|
||||
redirect:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -1791,6 +1799,8 @@ paths:
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'409':
|
||||
@@ -1947,16 +1957,16 @@ paths:
|
||||
description: 'Pre deployment command container.'
|
||||
manual_webhook_secret_github:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Github.'
|
||||
description: 'Manual webhook secret for Github. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitlab:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitlab.'
|
||||
description: 'Manual webhook secret for Gitlab. Needs the deploy ability.'
|
||||
manual_webhook_secret_bitbucket:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Bitbucket.'
|
||||
description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitea:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitea.'
|
||||
description: 'Manual webhook secret for Gitea. Needs the deploy ability.'
|
||||
redirect:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -2108,6 +2118,8 @@ paths:
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'409':
|
||||
@@ -2158,7 +2170,7 @@ paths:
|
||||
tags:
|
||||
- Applications
|
||||
summary: Delete
|
||||
description: 'Delete application by UUID.'
|
||||
description: 'Delete application by UUID. Needs a token with the write and deploy permissions.'
|
||||
operationId: delete-application-by-uuid
|
||||
parameters:
|
||||
-
|
||||
@@ -2215,6 +2227,8 @@ paths:
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -2391,16 +2405,16 @@ paths:
|
||||
description: 'Pre deployment command container.'
|
||||
manual_webhook_secret_github:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Github.'
|
||||
description: 'Manual webhook secret for Github. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitlab:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitlab.'
|
||||
description: 'Manual webhook secret for Gitlab. Needs the deploy ability.'
|
||||
manual_webhook_secret_bitbucket:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Bitbucket.'
|
||||
description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.'
|
||||
manual_webhook_secret_gitea:
|
||||
type: string
|
||||
description: 'Manual webhook secret for Gitea.'
|
||||
description: 'Manual webhook secret for Gitea. Needs the deploy ability.'
|
||||
redirect:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -2551,6 +2565,8 @@ paths:
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability to set a manual webhook secret.'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
@@ -3232,7 +3248,7 @@ paths:
|
||||
content:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'File content (file only, optional).'
|
||||
description: 'File content (file only, optional). Needs the deploy ability when set.'
|
||||
is_directory:
|
||||
type: boolean
|
||||
description: 'Whether this is a directory mount (file only, default false).'
|
||||
@@ -3255,7 +3271,7 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission for a mount outside the resource directory.'
|
||||
description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
@@ -3516,7 +3532,7 @@ paths:
|
||||
description: 'Optional name for the cloned application.'
|
||||
type: [string, 'null']
|
||||
clone_volumes:
|
||||
description: 'Whether to clone volume data.'
|
||||
description: 'Whether to clone volume data. Needs the deploy ability.'
|
||||
type: boolean
|
||||
default: false
|
||||
type: object
|
||||
@@ -3534,6 +3550,8 @@ paths:
|
||||
$ref: '#/components/responses/400'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability to clone volume data.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -4182,6 +4200,7 @@ paths:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Import database backup'
|
||||
description: 'Restore a backup into the database. Every import source requires the deploy ability.'
|
||||
operationId: create-database-import
|
||||
parameters:
|
||||
-
|
||||
@@ -4200,6 +4219,8 @@ paths:
|
||||
responses:
|
||||
'202':
|
||||
description: 'Import queued'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability.'
|
||||
'409':
|
||||
description: 'Another start, restart or import of this database is already in progress.'
|
||||
'422':
|
||||
@@ -4340,7 +4361,7 @@ paths:
|
||||
default: false
|
||||
backup_now:
|
||||
type: boolean
|
||||
description: 'Whether to trigger backup immediately after creation'
|
||||
description: 'Whether to trigger backup immediately after creation. Needs a token with the deploy permission.'
|
||||
database_backup_retention_amount_locally:
|
||||
type: integer
|
||||
description: 'Number of backups to retain locally'
|
||||
@@ -4384,6 +4405,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission to use backup_now.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -4427,7 +4450,7 @@ paths:
|
||||
tags:
|
||||
- Databases
|
||||
summary: Delete
|
||||
description: 'Delete database by UUID.'
|
||||
description: 'Delete database by UUID. Needs a token with the write and deploy permissions.'
|
||||
operationId: delete-database-by-uuid
|
||||
parameters:
|
||||
-
|
||||
@@ -4484,6 +4507,8 @@ paths:
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -4519,10 +4544,10 @@ paths:
|
||||
description: 'Docker Image of the database'
|
||||
is_public:
|
||||
type: boolean
|
||||
description: 'Is the database public?'
|
||||
description: 'Is the database public? Changing it starts or stops the database proxy, so it needs a token with the deploy permission.'
|
||||
public_port:
|
||||
type: integer
|
||||
description: 'Public port of the database'
|
||||
description: 'Public port of the database (1024-65535)'
|
||||
public_port_timeout:
|
||||
type: integer
|
||||
description: 'Public port timeout in seconds (default: 3600)'
|
||||
@@ -4666,6 +4691,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission to change is_public or to use instant_deploy.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -4759,7 +4786,7 @@ paths:
|
||||
description: 'S3 storage UUID'
|
||||
backup_now:
|
||||
type: boolean
|
||||
description: 'Whether to take a backup now or not'
|
||||
description: 'Whether to take a backup now or not. Needs a token with the deploy permission.'
|
||||
enabled:
|
||||
type: boolean
|
||||
description: 'Whether the backup is enabled or not'
|
||||
@@ -4807,6 +4834,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission to use backup_now.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -6388,7 +6417,7 @@ paths:
|
||||
content:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'File content (file only, optional).'
|
||||
description: 'File content (file only, optional). Needs the deploy ability when set.'
|
||||
is_directory:
|
||||
type: boolean
|
||||
description: 'Whether this is a directory mount (file only, default false).'
|
||||
@@ -6411,7 +6440,7 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission for a mount outside the resource directory.'
|
||||
description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
@@ -6670,6 +6699,7 @@ paths:
|
||||
name:
|
||||
type: [string, 'null']
|
||||
clone_volumes:
|
||||
description: 'Whether to clone volume data. Needs the deploy ability.'
|
||||
type: boolean
|
||||
default: false
|
||||
type: object
|
||||
@@ -6680,6 +6710,8 @@ paths:
|
||||
$ref: '#/components/responses/400'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability to clone volume data.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -6957,7 +6989,7 @@ paths:
|
||||
tags:
|
||||
- Destinations
|
||||
summary: 'Create destination'
|
||||
description: 'Create a Docker network destination on a server owned by the authenticated team.'
|
||||
description: "Create a Docker network destination on a server owned by the authenticated team. The network must be new: Docker's built-in networks (bridge, host, none) and networks that already exist on a standalone server are rejected, because Coolify connects the proxy to the destination network."
|
||||
operationId: create-server-destination
|
||||
parameters:
|
||||
-
|
||||
@@ -6979,6 +7011,7 @@ paths:
|
||||
type: string
|
||||
maxLength: 255
|
||||
network:
|
||||
description: 'Name of a new Docker network. Must not be bridge, host, none, or a network that already exists on the server.'
|
||||
type: string
|
||||
maxLength: 255
|
||||
pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'
|
||||
@@ -7000,7 +7033,7 @@ paths:
|
||||
'409':
|
||||
description: 'A destination with this network already exists.'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
description: 'Validation failed, the network is reserved or already exists on the server, or the server could not be checked.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -7230,12 +7263,15 @@ paths:
|
||||
tags:
|
||||
- DigitalOcean
|
||||
summary: 'Create a server on DigitalOcean'
|
||||
description: 'Create a DigitalOcean droplet and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.'
|
||||
operationId: create-digitalocean-server
|
||||
responses:
|
||||
'201':
|
||||
description: 'DigitalOcean droplet created and linked to a Coolify server.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'
|
||||
'422':
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
@@ -8029,7 +8065,7 @@ paths:
|
||||
tags:
|
||||
- Hetzner
|
||||
summary: 'Create Hetzner Server'
|
||||
description: 'Create a new server on Hetzner and register it in Coolify.'
|
||||
description: 'Create a new server on Hetzner and register it in Coolify. A cloud-init script or extra SSH key IDs need a token with the deploy permission.'
|
||||
operationId: create-hetzner-server
|
||||
requestBody:
|
||||
description: 'Hetzner server creation parameters'
|
||||
@@ -8087,7 +8123,7 @@ paths:
|
||||
hetzner_ssh_key_ids:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
description: 'Additional Hetzner SSH key IDs'
|
||||
description: 'Additional Hetzner SSH key IDs. Needs a token with the deploy permission.'
|
||||
hetzner_firewall_ids:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
@@ -8098,7 +8134,7 @@ paths:
|
||||
description: 'Existing Hetzner network IDs to attach during server creation'
|
||||
cloud_init_script:
|
||||
type: string
|
||||
description: 'Cloud-init YAML script (optional)'
|
||||
description: 'Cloud-init YAML script (optional). Needs a token with the deploy permission.'
|
||||
instant_validate:
|
||||
type: boolean
|
||||
example: false
|
||||
@@ -8119,6 +8155,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -10167,7 +10205,7 @@ paths:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Update Docker cleanup settings'
|
||||
description: 'Update Docker cleanup settings for a server owned by the authenticated team.'
|
||||
description: 'Update Docker cleanup settings for a server owned by the authenticated team. Turning on `delete_unused_volumes` needs a token with the `deploy` permission.'
|
||||
operationId: update-server-docker-cleanup
|
||||
parameters:
|
||||
-
|
||||
@@ -10217,6 +10255,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'Turning on `delete_unused_volumes` needs the deploy permission.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -10229,7 +10269,7 @@ paths:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Run Docker cleanup'
|
||||
description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team.'
|
||||
description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team. Needs a token with the write and deploy permissions.'
|
||||
operationId: run-server-docker-cleanup
|
||||
parameters:
|
||||
-
|
||||
@@ -10263,6 +10303,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -10342,7 +10384,7 @@ paths:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Log in to a registry on a server'
|
||||
description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login.'
|
||||
description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login. Needs a token with the write and deploy permissions.'
|
||||
operationId: login-server-registry
|
||||
parameters:
|
||||
-
|
||||
@@ -10386,6 +10428,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -10522,7 +10566,7 @@ paths:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Update log drain settings'
|
||||
description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.'
|
||||
description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Needs a token with the write and deploy permissions. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.'
|
||||
operationId: update-server-log-drains
|
||||
parameters:
|
||||
-
|
||||
@@ -10565,7 +10609,7 @@ paths:
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'Missing the deploy permission for a custom log drain change.'
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -10616,7 +10660,7 @@ paths:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Update server proxy'
|
||||
description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.'
|
||||
description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server. Changing proxy_type needs a token with the `write` and `deploy` permissions.'
|
||||
operationId: update-server-proxy
|
||||
parameters:
|
||||
-
|
||||
@@ -10643,7 +10687,7 @@ paths:
|
||||
description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.'
|
||||
type: [string, 'null']
|
||||
proxy_type:
|
||||
description: 'Proxy type (case-insensitive).'
|
||||
description: 'Proxy type (case-insensitive). Changing it starts the new proxy on the server and needs the deploy permission.'
|
||||
type: string
|
||||
enum: [traefik, caddy, nginx, none]
|
||||
type: object
|
||||
@@ -10666,6 +10710,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission to change the proxy type.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -10812,7 +10858,7 @@ paths:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Update Sentinel settings'
|
||||
description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel.'
|
||||
description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel. Needs a token with the write and deploy permissions.'
|
||||
operationId: update-server-sentinel
|
||||
parameters:
|
||||
-
|
||||
@@ -10879,6 +10925,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -10925,7 +10973,7 @@ paths:
|
||||
name:
|
||||
type: string
|
||||
example: 'My Server'
|
||||
description: 'The name of the server.'
|
||||
description: 'The name of the server. Must not contain control characters such as new lines.'
|
||||
description:
|
||||
type: string
|
||||
example: 'My Server Description'
|
||||
@@ -11020,7 +11068,7 @@ paths:
|
||||
tags:
|
||||
- Servers
|
||||
summary: Delete
|
||||
description: 'Delete server by UUID.'
|
||||
description: 'Delete server by UUID. With force or delete_from_provider, needs a token with the write and deploy permissions.'
|
||||
operationId: delete-server-by-uuid
|
||||
parameters:
|
||||
-
|
||||
@@ -11033,7 +11081,7 @@ paths:
|
||||
-
|
||||
name: force
|
||||
in: query
|
||||
description: 'Also delete all resources on the server.'
|
||||
description: 'Also delete all resources on the server. Needs the deploy permission.'
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
@@ -11041,12 +11089,14 @@ paths:
|
||||
-
|
||||
name: delete_from_provider
|
||||
in: query
|
||||
description: 'Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone.'
|
||||
description: 'Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone. Needs the deploy permission.'
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
'403':
|
||||
description: 'The token needs the deploy permission to delete the resources or the cloud server.'
|
||||
'200':
|
||||
description: 'Server deleted.'
|
||||
content:
|
||||
@@ -11070,7 +11120,7 @@ paths:
|
||||
tags:
|
||||
- Servers
|
||||
summary: Update
|
||||
description: 'Update Server.'
|
||||
description: 'Update Server. Changing proxy_type needs a token with the write and deploy permissions.'
|
||||
operationId: update-server-by-uuid
|
||||
parameters:
|
||||
-
|
||||
@@ -11089,7 +11139,7 @@ paths:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: 'The name of the server.'
|
||||
description: 'The name of the server. Must not contain control characters such as new lines.'
|
||||
description:
|
||||
type: string
|
||||
description: 'The description of the server.'
|
||||
@@ -11119,7 +11169,7 @@ paths:
|
||||
proxy_type:
|
||||
type: string
|
||||
enum: [traefik, caddy, none]
|
||||
description: 'The proxy type.'
|
||||
description: 'The proxy type. Changing it starts the new proxy on the server and needs the deploy permission.'
|
||||
concurrent_builds:
|
||||
type: integer
|
||||
description: 'Number of concurrent builds.'
|
||||
@@ -11143,6 +11193,8 @@ paths:
|
||||
description: 'SSH connection timeout in seconds (1-300). Default: 10.'
|
||||
type: object
|
||||
responses:
|
||||
'403':
|
||||
description: 'The token needs the deploy permission to change the proxy type.'
|
||||
'201':
|
||||
description: 'Server updated.'
|
||||
content:
|
||||
@@ -11229,7 +11281,7 @@ paths:
|
||||
tags:
|
||||
- Servers
|
||||
summary: Validate
|
||||
description: 'Validate server by UUID.'
|
||||
description: 'Validate server by UUID. With install=true, needs a token with the write and deploy permissions.'
|
||||
operationId: validate-server-by-uuid
|
||||
parameters:
|
||||
-
|
||||
@@ -11246,11 +11298,13 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
install:
|
||||
description: 'Install missing prerequisites and Docker. This can restart the Docker daemon.'
|
||||
description: 'Install missing prerequisites and Docker, then start the proxy. This can restart the Docker daemon. Needs the deploy permission.'
|
||||
type: boolean
|
||||
default: false
|
||||
type: object
|
||||
responses:
|
||||
'403':
|
||||
description: 'The token needs the deploy permission to install the server.'
|
||||
'201':
|
||||
description: 'Server validation started.'
|
||||
content:
|
||||
@@ -11703,6 +11757,7 @@ paths:
|
||||
tags:
|
||||
- 'Service databases'
|
||||
summary: 'Import service database backup'
|
||||
description: 'Restore a backup into the service database. Every import source requires the deploy ability.'
|
||||
operationId: create-service-database-import
|
||||
parameters:
|
||||
-
|
||||
@@ -11728,6 +11783,8 @@ paths:
|
||||
responses:
|
||||
'202':
|
||||
description: 'Import queued'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability.'
|
||||
'409':
|
||||
description: 'Another start, restart or import of this database is already in progress.'
|
||||
'422':
|
||||
@@ -11879,6 +11936,7 @@ paths:
|
||||
is_log_drain_enabled:
|
||||
type: boolean
|
||||
is_public:
|
||||
description: 'Changing it starts or stops the database proxy, so it needs a token with the deploy permission.'
|
||||
type: boolean
|
||||
public_port:
|
||||
type: [integer, 'null']
|
||||
@@ -11900,6 +11958,8 @@ paths:
|
||||
$ref: '#/components/responses/400'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission to change is_public.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -12256,7 +12316,7 @@ paths:
|
||||
tags:
|
||||
- Services
|
||||
summary: Delete
|
||||
description: 'Delete service by UUID.'
|
||||
description: 'Delete service by UUID. Needs a token with the write and deploy permissions.'
|
||||
operationId: delete-service-by-uuid
|
||||
parameters:
|
||||
-
|
||||
@@ -12321,6 +12381,8 @@ paths:
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -12997,7 +13059,7 @@ paths:
|
||||
content:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'File content (file only, optional).'
|
||||
description: 'File content (file only, optional). Needs the deploy ability when set.'
|
||||
is_directory:
|
||||
type: boolean
|
||||
description: 'Whether this is a directory mount (file only, default false).'
|
||||
@@ -13020,7 +13082,7 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission for a mount outside the resource directory.'
|
||||
description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
@@ -13279,6 +13341,7 @@ paths:
|
||||
name:
|
||||
type: [string, 'null']
|
||||
clone_volumes:
|
||||
description: 'Whether to clone volume data. Needs the deploy ability.'
|
||||
type: boolean
|
||||
default: false
|
||||
type: object
|
||||
@@ -13289,6 +13352,8 @@ paths:
|
||||
$ref: '#/components/responses/400'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy ability to clone volume data.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -14094,7 +14159,7 @@ paths:
|
||||
tags:
|
||||
- Applications
|
||||
summary: 'Set application storage backup schedule'
|
||||
description: 'Create or replace the backup schedule for an application persistent volume or directory storage.'
|
||||
description: 'Create or replace the backup schedule for an application persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.'
|
||||
operationId: set-application-storage-backup-schedule
|
||||
parameters:
|
||||
-
|
||||
@@ -14135,7 +14200,7 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: Forbidden.
|
||||
description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -14181,7 +14246,7 @@ paths:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Set database storage backup schedule'
|
||||
description: 'Create or replace the backup schedule for a database persistent volume or directory storage.'
|
||||
description: 'Create or replace the backup schedule for a database persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.'
|
||||
operationId: set-database-storage-backup-schedule
|
||||
parameters:
|
||||
-
|
||||
@@ -14222,7 +14287,7 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: Forbidden.
|
||||
description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -14268,7 +14333,7 @@ paths:
|
||||
tags:
|
||||
- Services
|
||||
summary: 'Set service storage backup schedule'
|
||||
description: 'Create or replace the backup schedule for a service persistent volume or directory storage.'
|
||||
description: 'Create or replace the backup schedule for a service persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.'
|
||||
operationId: set-service-storage-backup-schedule
|
||||
parameters:
|
||||
-
|
||||
@@ -14309,7 +14374,7 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: Forbidden.
|
||||
description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
@@ -14355,7 +14420,7 @@ paths:
|
||||
tags:
|
||||
- Applications
|
||||
summary: 'Run application storage backup'
|
||||
description: 'Queue an immediate volume backup for an application storage that has a schedule.'
|
||||
description: 'Queue an immediate volume backup for an application storage that has a schedule. Needs a token with the write and deploy permissions.'
|
||||
operationId: run-application-storage-backup
|
||||
parameters:
|
||||
-
|
||||
@@ -14375,6 +14440,8 @@ paths:
|
||||
description: 'Storage backup queued.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
@@ -14385,7 +14452,7 @@ paths:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Run database storage backup'
|
||||
description: 'Queue an immediate volume backup for a database storage that has a schedule.'
|
||||
description: 'Queue an immediate volume backup for a database storage that has a schedule. Needs a token with the write and deploy permissions.'
|
||||
operationId: run-database-storage-backup
|
||||
parameters:
|
||||
-
|
||||
@@ -14405,6 +14472,8 @@ paths:
|
||||
description: 'Storage backup queued.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
@@ -14415,7 +14484,7 @@ paths:
|
||||
tags:
|
||||
- Services
|
||||
summary: 'Run service storage backup'
|
||||
description: 'Queue an immediate volume backup for a service storage that has a schedule.'
|
||||
description: 'Queue an immediate volume backup for a service storage that has a schedule. Needs a token with the write and deploy permissions.'
|
||||
operationId: run-service-storage-backup
|
||||
parameters:
|
||||
-
|
||||
@@ -14435,6 +14504,8 @@ paths:
|
||||
description: 'Storage backup queued.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the write and deploy permissions.'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
@@ -14513,13 +14584,15 @@ paths:
|
||||
tags:
|
||||
- Vultr
|
||||
summary: 'Create Vultr Server'
|
||||
description: 'Create a Vultr instance and link it as a Coolify server.'
|
||||
description: 'Create a Vultr instance and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.'
|
||||
operationId: create-vultr-server
|
||||
responses:
|
||||
'201':
|
||||
description: 'Vultr server created.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'
|
||||
'422':
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
@@ -14748,6 +14821,7 @@ components:
|
||||
type: boolean
|
||||
default: false
|
||||
stop_during_backup:
|
||||
description: 'Stop the resource containers during the backup. Needs a token with the deploy permission.'
|
||||
type: boolean
|
||||
default: false
|
||||
s3_storage_uuid:
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<div class="listbox-option p-0!">
|
||||
<x-forms.checkbox id="permission-deploy" label="Deploy" fullWidth
|
||||
wire:model.live="permissions" domValue="deploy"
|
||||
helper="Trigger deployments through webhooks."
|
||||
helper="Deploy, start, stop, and restart resources. With Write, also apply changes that run on servers immediately, such as scheduled tasks, proxy configuration, and imports."
|
||||
:checked="in_array('deploy', $permissions)"
|
||||
:disabled="in_array('root', $permissions) || !$canUseDeployPermissions" />
|
||||
</div>
|
||||
|
||||
+12
-12
@@ -169,14 +169,14 @@ Route::group([
|
||||
// Server subsystem APIs (Docker cleanup, log drains, Sentinel, Cloudflare Tunnel).
|
||||
Route::get('/servers/{uuid}/docker-cleanup', [ServerDockerCleanupController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::patch('/servers/{uuid}/docker-cleanup', [ServerDockerCleanupController::class, 'update'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/{uuid}/docker-cleanup/run', [ServerDockerCleanupController::class, 'run'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/{uuid}/docker-cleanup/run', [ServerDockerCleanupController::class, 'run'])->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
Route::get('/servers/{uuid}/docker-cleanup/executions', [ServerDockerCleanupController::class, 'executions'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/servers/{uuid}/log-drains', [ServerLogDrainsController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::patch('/servers/{uuid}/log-drains', [ServerLogDrainsController::class, 'update'])->middleware(['api.ability:write']);
|
||||
Route::patch('/servers/{uuid}/log-drains', [ServerLogDrainsController::class, 'update'])->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
|
||||
Route::get('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::patch('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'update'])->middleware(['api.ability:write']);
|
||||
Route::patch('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'update'])->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
|
||||
Route::get('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::patch('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'update'])->middleware(['api.ability:write']);
|
||||
@@ -195,7 +195,7 @@ Route::group([
|
||||
Route::post('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'index'])->middleware(['api.ability:read:sensitive']);
|
||||
Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
Route::post('/servers/{uuid}/registries/{registry}/check', [ServerDockerRegistriesController::class, 'check'])->middleware(['api.ability:write']);
|
||||
Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:write']);
|
||||
|
||||
@@ -205,7 +205,7 @@ Route::group([
|
||||
Route::post('/servers/{uuid}/proxy/restart', [ServerProxyController::class, 'restart'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
Route::get('/servers/{uuid}/export', [ServerTransferController::class, 'export'])->middleware(['api.ability:read']);
|
||||
Route::post('/servers/{uuid}/export/mailbox', [ServerTransferController::class, 'writeMailbox'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']);
|
||||
@@ -249,7 +249,7 @@ Route::group([
|
||||
|
||||
Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']);
|
||||
Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
Route::patch('/applications/{uuid}/secret-manager', [ApplicationSecretManagerController::class, 'update'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs'])->middleware(['api.ability:read']);
|
||||
@@ -268,7 +268,7 @@ Route::group([
|
||||
->middleware(['api.ability:write']);
|
||||
Route::post('/applications/{uuid}/storages/{storage_uuid}/backups/run', [VolumeBackupsController::class, 'run'])
|
||||
->defaults('resource_type', 'application')
|
||||
->middleware(['api.ability:write']);
|
||||
->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
Route::delete('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy'])
|
||||
->defaults('resource_type', 'application')
|
||||
->middleware(['api.ability:write']);
|
||||
@@ -296,7 +296,7 @@ Route::group([
|
||||
Route::post('/applications/{uuid}/previews', [ApplicationPreviewsController::class, 'store'])->middleware(['api.ability:deploy']);
|
||||
Route::get('/applications/{uuid}/previews/{pull_request_id}', [ApplicationPreviewsController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::patch('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'update_preview_by_pull_request_id'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write', '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']);
|
||||
@@ -330,7 +330,7 @@ Route::group([
|
||||
Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']);
|
||||
Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']);
|
||||
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']);
|
||||
@@ -344,7 +344,7 @@ Route::group([
|
||||
->middleware(['api.ability:write']);
|
||||
Route::post('/databases/{uuid}/storages/{storage_uuid}/backups/run', [VolumeBackupsController::class, 'run'])
|
||||
->defaults('resource_type', 'database')
|
||||
->middleware(['api.ability:write']);
|
||||
->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
Route::delete('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy'])
|
||||
->defaults('resource_type', 'database')
|
||||
->middleware(['api.ability:write']);
|
||||
@@ -374,7 +374,7 @@ Route::group([
|
||||
|
||||
Route::get('/services/{uuid}', [ServicesController::class, 'service_by_uuid'])->middleware(['api.ability:read']);
|
||||
Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
|
||||
Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']);
|
||||
Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']);
|
||||
@@ -385,7 +385,7 @@ Route::group([
|
||||
->middleware(['api.ability:write']);
|
||||
Route::post('/services/{uuid}/storages/{storage_uuid}/backups/run', [VolumeBackupsController::class, 'run'])
|
||||
->defaults('resource_type', 'service')
|
||||
->middleware(['api.ability:write']);
|
||||
->middleware(['api.ability:write', 'api.ability:deploy']);
|
||||
Route::delete('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy'])
|
||||
->defaults('resource_type', 'service')
|
||||
->middleware(['api.ability:write']);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first();
|
||||
$destinationAttributes = [
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
];
|
||||
|
||||
$this->resources = [
|
||||
'applications' => Application::factory()->create($destinationAttributes),
|
||||
'services' => Service::factory()->create($destinationAttributes + ['server_id' => $server->id]),
|
||||
'databases' => StandalonePostgresql::create($destinationAttributes + [
|
||||
'name' => 'source-db',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
]),
|
||||
];
|
||||
|
||||
Queue::fake();
|
||||
Bus::fake();
|
||||
});
|
||||
|
||||
function cloneAbilityHeaders(User $user, int $teamId, array $abilities): array
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'clone-volumes-ability-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
|
||||
}
|
||||
|
||||
test('write-only token cannot clone volume data', function (string $type) {
|
||||
$resource = $this->resources[$type];
|
||||
$countBefore = $resource::query()->count();
|
||||
|
||||
$this->withHeaders(cloneAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/{$type}/{$resource->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
'clone_volumes' => true,
|
||||
])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.');
|
||||
|
||||
expect($resource::query()->count())->toBe($countBefore);
|
||||
})->with(['applications', 'services', 'databases']);
|
||||
|
||||
test('write-only token can clone without volume data', function (string $type) {
|
||||
$resource = $this->resources[$type];
|
||||
$countBefore = $resource::query()->count();
|
||||
|
||||
$this->withHeaders(cloneAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/{$type}/{$resource->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
'clone_volumes' => false,
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
expect($resource::query()->count())->toBe($countBefore + 1);
|
||||
})->with(['applications', 'services', 'databases']);
|
||||
|
||||
test('write and deploy token passes the volume clone check', function (string $type) {
|
||||
$resource = $this->resources[$type];
|
||||
|
||||
// An unknown destination stops the request right after the ability check, before any server work.
|
||||
$this->withHeaders(cloneAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->postJson("/api/v1/{$type}/{$resource->uuid}/clone", [
|
||||
'destination_uuid' => 'missing-destination',
|
||||
'clone_volumes' => true,
|
||||
])
|
||||
->assertNotFound()
|
||||
->assertJsonPath('message', 'Destination not found.');
|
||||
})->with(['applications', 'services', 'databases']);
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('cache.default', 'array');
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first();
|
||||
$this->destinationAttributes = [
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
];
|
||||
|
||||
$this->database = StandalonePostgresql::create($this->destinationAttributes + [
|
||||
'name' => 'deploy-ability-db',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
Queue::fake();
|
||||
Http::fake();
|
||||
});
|
||||
|
||||
function deployAbilityHeaders(User $user, int $teamId, array $abilities): array
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'cloud-init-public-backup-ability-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
|
||||
}
|
||||
|
||||
dataset('cloud providers', [
|
||||
'hetzner' => ['hetzner', ['location' => 'nbg1', 'server_type' => 'cx11', 'image' => 15512617], 'hetzner_ssh_key_ids', [123]],
|
||||
'vultr' => ['vultr', ['region' => 'ewr', 'plan' => 'vc2-1c-1gb', 'os_id' => 2284], 'vultr_ssh_key_ids', ['key-1']],
|
||||
'digitalocean' => ['digitalocean', ['region' => 'nyc1', 'size' => 's-1vcpu-1gb', 'image' => 'ubuntu-24-04-x64'], 'digitalocean_ssh_key_ids', [123]],
|
||||
]);
|
||||
|
||||
function cloudServerPayload(object $context, string $provider, array $providerFields, ?string $tokenUuid = null): array
|
||||
{
|
||||
$token = CloudProviderToken::create([
|
||||
'team_id' => $context->team->id,
|
||||
'provider' => $provider,
|
||||
'name' => 'Test token',
|
||||
'token' => 'test-api-token',
|
||||
]);
|
||||
$privateKey = PrivateKey::create([
|
||||
'team_id' => $context->team->id,
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
|
||||
return $providerFields + [
|
||||
'cloud_provider_token_uuid' => $tokenUuid ?? $token->uuid,
|
||||
'name' => 'deploy-ability-server',
|
||||
'private_key_uuid' => $privateKey->uuid,
|
||||
];
|
||||
}
|
||||
|
||||
test('write-only token cannot create a cloud server with a cloud-init script or extra SSH keys', function (string $provider, array $providerFields, string $sshKeyField, array $sshKeyIds, string $extra) {
|
||||
$payload = cloudServerPayload($this, $provider, $providerFields);
|
||||
$payload += $extra === 'cloud_init_script'
|
||||
? ['cloud_init_script' => "#cloud-config\nruncmd:\n - echo hello\n"]
|
||||
: [$sshKeyField => $sshKeyIds];
|
||||
$serverCount = Server::query()->count();
|
||||
|
||||
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/servers/{$provider}", $payload)
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.');
|
||||
|
||||
Http::assertNothingSent();
|
||||
expect(Server::query()->count())->toBe($serverCount);
|
||||
})->with('cloud providers')->with(['cloud_init_script', 'ssh_key_ids']);
|
||||
|
||||
test('write and deploy token passes the cloud-init check', function (string $provider, array $providerFields, string $sshKeyField, array $sshKeyIds) {
|
||||
// An unknown provider token stops the request after the ability check, before any provider call.
|
||||
$payload = cloudServerPayload($this, $provider, $providerFields, 'missing-token') + [
|
||||
'cloud_init_script' => "#cloud-config\nruncmd:\n - echo hello\n",
|
||||
$sshKeyField => $sshKeyIds,
|
||||
];
|
||||
|
||||
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->postJson("/api/v1/servers/{$provider}", $payload)
|
||||
->assertNotFound();
|
||||
|
||||
Http::assertNothingSent();
|
||||
})->with('cloud providers');
|
||||
|
||||
test('write-only token can create a cloud server request without cloud-init or extra SSH keys', function (string $provider, array $providerFields) {
|
||||
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/servers/{$provider}", cloudServerPayload($this, $provider, $providerFields, 'missing-token'))
|
||||
->assertNotFound();
|
||||
})->with('cloud providers');
|
||||
|
||||
test('write-only token cannot make a database public', function () {
|
||||
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_public' => true, 'public_port' => 25432])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.');
|
||||
|
||||
expect((bool) $this->database->fresh()->is_public)->toBeFalse();
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('write-only token can send an unchanged is_public value', function () {
|
||||
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_public' => false, 'name' => 'renamed-db'])
|
||||
->assertOk();
|
||||
|
||||
expect($this->database->fresh()->name)->toBe('renamed-db');
|
||||
});
|
||||
|
||||
test('write and deploy token can make a database public', function () {
|
||||
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_public' => true, 'public_port' => 25432])
|
||||
->assertOk();
|
||||
|
||||
expect((bool) $this->database->fresh()->is_public)->toBeTrue();
|
||||
});
|
||||
|
||||
test('database update rejects a public port outside 1024-65535', function (int $port) {
|
||||
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->patchJson("/api/v1/databases/{$this->database->uuid}", ['public_port' => $port])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('public_port');
|
||||
|
||||
expect($this->database->fresh()->public_port)->toBeNull();
|
||||
})->with([80, 70000]);
|
||||
|
||||
test('service database is_public change needs the deploy permission', function (array $abilities, int $status) {
|
||||
$service = Service::factory()->create($this->destinationAttributes + [
|
||||
'server_id' => $this->server->id,
|
||||
'docker_compose_raw' => "services:\n postgres:\n image: postgres:17-alpine\n",
|
||||
]);
|
||||
$serviceDatabase = ServiceDatabase::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'postgres',
|
||||
'service_id' => $service->id,
|
||||
'image' => 'postgres:17-alpine',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities))
|
||||
->patchJson("/api/v1/services/{$service->uuid}/databases/{$serviceDatabase->uuid}", ['is_public' => true, 'public_port' => 25433])
|
||||
->assertStatus($status);
|
||||
|
||||
if ($status === 403) {
|
||||
$response->assertJsonPath('message', 'Missing required permissions: deploy. Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.');
|
||||
}
|
||||
expect((bool) $serviceDatabase->fresh()->is_public)->toBe($status === 200);
|
||||
})->with([
|
||||
'write only' => [['write'], 403],
|
||||
'write and deploy' => [['write', 'deploy'], 200],
|
||||
]);
|
||||
|
||||
test('backup_now needs the deploy permission when creating a database backup', function (array $abilities, int $status) {
|
||||
$response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities))
|
||||
->postJson("/api/v1/databases/{$this->database->uuid}/backups", ['frequency' => 'daily', 'backup_now' => true])
|
||||
->assertStatus($status);
|
||||
|
||||
if ($status === 403) {
|
||||
$response->assertJsonPath('message', 'Missing required permissions: deploy. A backup that runs now copies the database on the server, so it needs a token with the deploy permission.');
|
||||
expect(ScheduledDatabaseBackup::query()->count())->toBe(0);
|
||||
Queue::assertNotPushed(DatabaseBackupJob::class);
|
||||
} else {
|
||||
Queue::assertPushed(DatabaseBackupJob::class);
|
||||
}
|
||||
})->with([
|
||||
'write only' => [['write'], 403],
|
||||
'write and deploy' => [['write', 'deploy'], 201],
|
||||
]);
|
||||
|
||||
test('write-only token can create a database backup schedule without backup_now', function () {
|
||||
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/databases/{$this->database->uuid}/backups", ['frequency' => 'daily'])
|
||||
->assertCreated();
|
||||
|
||||
Queue::assertNotPushed(DatabaseBackupJob::class);
|
||||
});
|
||||
|
||||
test('backup_now needs the deploy permission when updating a database backup', function (array $abilities, int $status) {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => 'daily',
|
||||
'database_id' => $this->database->id,
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities))
|
||||
->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", ['frequency' => 'hourly', 'backup_now' => true])
|
||||
->assertStatus($status);
|
||||
|
||||
if ($status === 403) {
|
||||
$response->assertJsonPath('message', 'Missing required permissions: deploy. A backup that runs now copies the database on the server, so it needs a token with the deploy permission.');
|
||||
expect($backup->fresh()->frequency)->toBe('daily');
|
||||
Queue::assertNotPushed(DatabaseBackupJob::class);
|
||||
} else {
|
||||
expect($backup->fresh()->frequency)->toBe('hourly');
|
||||
Queue::assertPushed(DatabaseBackupJob::class);
|
||||
}
|
||||
})->with([
|
||||
'write only' => [['write'], 403],
|
||||
'write and deploy' => [['write', 'deploy'], 200],
|
||||
]);
|
||||
|
||||
test('stop_during_backup needs the deploy permission for a volume backup schedule', function (array $abilities, bool $stopDuringBackup, int $status) {
|
||||
$application = Application::factory()->create($this->destinationAttributes);
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'name' => 'deploy-ability-volume',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities))
|
||||
->putJson("/api/v1/applications/{$application->uuid}/storages/{$volume->uuid}/backups", [
|
||||
'frequency' => '0 2 * * *',
|
||||
'stop_during_backup' => $stopDuringBackup,
|
||||
])
|
||||
->assertStatus($status);
|
||||
|
||||
if ($status === 403) {
|
||||
$response->assertJsonPath('message', 'Missing required permissions: deploy. A backup that stops the containers needs a token with the deploy permission.');
|
||||
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
||||
} else {
|
||||
expect(ScheduledVolumeBackup::query()->sole()->stop_during_backup)->toBe($stopDuringBackup);
|
||||
}
|
||||
})->with([
|
||||
'write only, stop' => [['write'], true, 403],
|
||||
'write only, no stop' => [['write'], false, 201],
|
||||
'write and deploy, stop' => [['write', 'deploy'], true, 201],
|
||||
]);
|
||||
|
||||
test('write-only token cannot run a volume backup now', function () {
|
||||
$application = Application::factory()->create($this->destinationAttributes);
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'name' => 'deploy-ability-volume',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/storages/{$volume->uuid}/backups/run")
|
||||
->assertForbidden();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
@@ -57,33 +57,32 @@ function expectServerSourceImportStarts(): void
|
||||
}
|
||||
|
||||
dataset('server source import variants', ['standalone', 'service']);
|
||||
dataset('import sources', [
|
||||
'upload' => [fn () => ['source' => 'upload', 'upload_id' => (string) Str::uuid()]],
|
||||
's3' => [fn () => ['source' => 's3', 's3_storage_uuid' => (string) Str::uuid(), 'path' => '/backups/backup.sql']],
|
||||
'server' => [fn () => ['source' => 'server', 'path' => '/tmp/backup.sql']],
|
||||
]);
|
||||
|
||||
test('a write token cannot import from a server path', function (string $variant) {
|
||||
test('a write token without deploy cannot start an import from any source', function (string $variant, array $payload) {
|
||||
$action = Mockery::mock(StartDatabaseImport::class);
|
||||
$action->shouldNotReceive('handle');
|
||||
app()->instance(StartDatabaseImport::class, $action);
|
||||
|
||||
$this->withToken(serverSourceImportToken($this->user, $this->team, ['read', 'write']))
|
||||
->postJson($this->importPaths[$variant], ['source' => 'server', 'path' => '/etc/hostname'])
|
||||
->postJson($this->importPaths[$variant], $payload)
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. An import from a server path needs a token with the deploy permission.');
|
||||
})->with('server source import variants');
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. An import runs the dump in the database container, so it needs a token with the deploy permission.');
|
||||
|
||||
test('a write token with deploy or a root token can import from a server path', function (string $variant, array $abilities) {
|
||||
expect(Activity::query()->count())->toBe(0);
|
||||
})->with('server source import variants')->with('import sources');
|
||||
|
||||
test('a write token with deploy or a root token can start an import from any source', function (string $variant, array $payload, array $abilities) {
|
||||
expectServerSourceImportStarts();
|
||||
|
||||
$this->withToken(serverSourceImportToken($this->user, $this->team, $abilities))
|
||||
->postJson($this->importPaths[$variant], ['source' => 'server', 'path' => '/tmp/backup.sql'])
|
||||
->postJson($this->importPaths[$variant], $payload)
|
||||
->assertAccepted();
|
||||
})->with('server source import variants')->with([
|
||||
})->with('server source import variants')->with('import sources')->with([
|
||||
'write and deploy' => [['read', 'write', 'deploy']],
|
||||
'root' => [['root']],
|
||||
]);
|
||||
|
||||
test('a write token can still import from an upload without deploy', function () {
|
||||
expectServerSourceImportStarts();
|
||||
|
||||
$this->withToken(serverSourceImportToken($this->user, $this->team, ['read', 'write']))
|
||||
->postJson($this->importPaths['standalone'], ['source' => 'upload', 'upload_id' => (string) Str::uuid()])
|
||||
->assertAccepted();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
Queue::fake();
|
||||
Bus::fake();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first();
|
||||
$destinationAttributes = [
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
|
||||
$this->resources = [
|
||||
'applications' => Application::factory()->create($destinationAttributes),
|
||||
'services' => Service::factory()->create($destinationAttributes + ['server_id' => $server->id]),
|
||||
'databases' => StandalonePostgresql::create($destinationAttributes + [
|
||||
'name' => 'delete-db',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
]),
|
||||
];
|
||||
|
||||
$this->preview = ApplicationPreview::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $this->resources['applications']->id,
|
||||
'pull_request_id' => 42,
|
||||
'pull_request_html_url' => 'https://github.com/example/repo/pull/42',
|
||||
'fqdn' => 'pr-42.example.com',
|
||||
]);
|
||||
|
||||
CleanupPreviewDeployment::shouldRun()->andReturn([
|
||||
'cancelled_deployments' => 0,
|
||||
'killed_containers' => 0,
|
||||
'status' => 'success',
|
||||
]);
|
||||
});
|
||||
|
||||
function deleteAbilityHeaders(User $user, int $teamId, array $abilities): array
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'delete-resource-ability-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
|
||||
}
|
||||
|
||||
test('write-only token cannot delete a resource', function (string $type) {
|
||||
$resource = $this->resources[$type];
|
||||
|
||||
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->deleteJson("/api/v1/{$type}/{$resource->uuid}")
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy');
|
||||
|
||||
expect($resource::query()->whereKey($resource->getKey())->exists())->toBeTrue();
|
||||
Queue::assertNotPushed(DeleteResourceJob::class);
|
||||
})->with(['applications', 'services', 'databases']);
|
||||
|
||||
test('write and deploy token deletes a resource', function (string $type) {
|
||||
$resource = $this->resources[$type];
|
||||
|
||||
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->deleteJson("/api/v1/{$type}/{$resource->uuid}")
|
||||
->assertOk();
|
||||
|
||||
expect($resource::query()->whereKey($resource->getKey())->exists())->toBeFalse();
|
||||
})->with(['applications', 'services', 'databases']);
|
||||
|
||||
test('write-only token cannot delete a preview deployment', function () {
|
||||
$application = $this->resources['applications'];
|
||||
|
||||
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->deleteJson("/api/v1/applications/{$application->uuid}/previews/42")
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy');
|
||||
|
||||
expect($this->preview->fresh()->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('write and deploy token deletes a preview deployment', function () {
|
||||
$application = $this->resources['applications'];
|
||||
|
||||
$this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->deleteJson("/api/v1/applications/{$application->uuid}/previews/42")
|
||||
->assertOk();
|
||||
|
||||
expect($this->preview->fresh()->trashed())->toBeTrue();
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
@@ -11,6 +12,7 @@ use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -21,7 +23,10 @@ beforeEach(function () {
|
||||
'session.driver' => 'array',
|
||||
'queue.default' => 'sync',
|
||||
'app.maintenance.driver' => 'file',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
// Creating a standalone destination lists the Docker networks on the server first.
|
||||
Process::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
|
||||
['id' => 0],
|
||||
@@ -34,7 +39,10 @@ beforeEach(function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->bearerToken = destinationsApiToken($this->user, $this->team, ['*']);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = $this->project->environments()->first();
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'manual_webhook_secret_github' => 'existing-secret-0123456789',
|
||||
]);
|
||||
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
function manualWebhookSecretAbilityHeaders(User $user, int $teamId, array $abilities): array
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'manual-webhook-secret-ability-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
|
||||
}
|
||||
|
||||
describe('update', function () {
|
||||
test('write-only token cannot set a new manual webhook secret', function (string $field) {
|
||||
$this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => 'attacker-secret-0123456789'])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.');
|
||||
|
||||
$application = $this->application->fresh();
|
||||
expect($application->manual_webhook_secret_github)->toBe('existing-secret-0123456789');
|
||||
expect($application->{$field})->not->toBe('attacker-secret-0123456789');
|
||||
})->with([
|
||||
'manual_webhook_secret_github',
|
||||
'manual_webhook_secret_gitlab',
|
||||
'manual_webhook_secret_bitbucket',
|
||||
'manual_webhook_secret_gitea',
|
||||
]);
|
||||
|
||||
test('write-only token can update other fields and send the stored secret back', function () {
|
||||
$headers = manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']);
|
||||
|
||||
$this->withHeaders($headers)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", ['description' => 'changed'])
|
||||
->assertOk();
|
||||
$this->withHeaders($headers)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'description' => 'changed again',
|
||||
'manual_webhook_secret_github' => 'existing-secret-0123456789',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect($this->application->fresh())
|
||||
->description->toBe('changed again')
|
||||
->manual_webhook_secret_github->toBe('existing-secret-0123456789');
|
||||
});
|
||||
|
||||
test('write and deploy token can set a new manual webhook secret', function () {
|
||||
$this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", ['manual_webhook_secret_gitlab' => 'new-gitlab-secret-0123456789'])
|
||||
->assertOk();
|
||||
|
||||
expect($this->application->fresh()->manual_webhook_secret_gitlab)->toBe('new-gitlab-secret-0123456789');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', function () {
|
||||
test('write-only token cannot create an application with a manual webhook secret', function () {
|
||||
$this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson('/api/v1/applications/dockerimage', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'docker_registry_image_name' => 'nginx',
|
||||
'ports_exposes' => '80',
|
||||
'manual_webhook_secret_github' => 'attacker-secret-0123456789',
|
||||
])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', fn (string $message) => str_contains($message, 'manual webhook secret'));
|
||||
|
||||
expect(Application::count())->toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ beforeEach(function () {
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$newToken = $this->user->createToken('write-token', ['write']);
|
||||
$newToken = $this->user->createToken('write-token', ['write', 'deploy']);
|
||||
$newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
|
||||
$this->token = $newToken->plainTextToken;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Jobs\ValidateAndInstallServerJob;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
'app.maintenance.driver' => 'file',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
Queue::fake();
|
||||
Bus::fake();
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'original-name',
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
||||
]);
|
||||
$this->server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$this->server->save();
|
||||
});
|
||||
|
||||
function serverDeployAbilityHeaders(User $user, int $teamId, array $abilities): array
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'server-deploy-ability-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
|
||||
}
|
||||
|
||||
describe('server name', function () {
|
||||
test('create rejects a name with control characters', function (string $name) {
|
||||
$countBefore = Server::query()->count();
|
||||
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson('/api/v1/servers', [
|
||||
'name' => $name,
|
||||
'ip' => '10.0.0.55',
|
||||
'private_key_uuid' => $this->server->privateKey->uuid,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('errors.name.0', 'The name must not contain control characters such as new lines.');
|
||||
|
||||
expect(Server::query()->count())->toBe($countBefore);
|
||||
})->with(['new line' => "bad\nname", 'carriage return' => "bad\rname", 'tab' => "bad\tname", 'null byte' => "bad\0name"]);
|
||||
|
||||
test('update rejects a name with a new line', function () {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}", ['name' => "bad\n[OUTPUT]\nname"])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('errors.name.0', 'The name must not contain control characters such as new lines.');
|
||||
|
||||
expect($this->server->fresh()->name)->toBe('original-name');
|
||||
});
|
||||
|
||||
test('update accepts a one-line name', function () {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}", ['name' => 'My Server (prod)'])
|
||||
->assertCreated();
|
||||
|
||||
expect($this->server->fresh()->name)->toBe('My Server (prod)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy type', function () {
|
||||
test('write-only token cannot change the proxy type', function (string $method, string $path) {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->json($method, str_replace('{uuid}', $this->server->uuid, $path), ['proxy_type' => 'caddy'])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. Changing the proxy type starts the new proxy on the server, so it needs a token with the deploy permission.');
|
||||
|
||||
expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::TRAEFIK->value);
|
||||
})->with([
|
||||
'server update' => ['PATCH', '/api/v1/servers/{uuid}'],
|
||||
'proxy update' => ['PATCH', '/api/v1/servers/{uuid}/proxy'],
|
||||
]);
|
||||
|
||||
test('write-only token can send the current proxy type', function (string $method, string $path) {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->json($method, str_replace('{uuid}', $this->server->uuid, $path), ['proxy_type' => 'traefik'])
|
||||
->assertSuccessful();
|
||||
|
||||
expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::TRAEFIK->value);
|
||||
})->with([
|
||||
'server update' => ['PATCH', '/api/v1/servers/{uuid}'],
|
||||
'proxy update' => ['PATCH', '/api/v1/servers/{uuid}/proxy'],
|
||||
]);
|
||||
|
||||
test('write and deploy token can change the proxy type', function (string $method, string $path) {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->json($method, str_replace('{uuid}', $this->server->uuid, $path), ['proxy_type' => 'caddy'])
|
||||
->assertSuccessful();
|
||||
|
||||
expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::CADDY->value);
|
||||
})->with([
|
||||
'server update' => ['PATCH', '/api/v1/servers/{uuid}'],
|
||||
'proxy update' => ['PATCH', '/api/v1/servers/{uuid}/proxy'],
|
||||
]);
|
||||
});
|
||||
|
||||
describe('validate with install', function () {
|
||||
test('write-only token cannot install the server', function () {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => true])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. Installing the server runs commands on it and starts the proxy, so it needs a token with the deploy permission.');
|
||||
|
||||
Bus::assertNotDispatched(ValidateAndInstallServerJob::class);
|
||||
});
|
||||
|
||||
test('write-only token can validate without install', function () {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/validate")
|
||||
->assertCreated()
|
||||
->assertJsonPath('message', 'Validation started.');
|
||||
|
||||
Bus::assertNotDispatched(ValidateAndInstallServerJob::class);
|
||||
});
|
||||
|
||||
test('write and deploy token can install the server', function () {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => true])
|
||||
->assertCreated()
|
||||
->assertJsonPath('message', 'Validation and installation started.');
|
||||
|
||||
Bus::assertDispatched(ValidateAndInstallServerJob::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', function () {
|
||||
test('write-only token cannot delete with force or delete_from_provider', function (string $query) {
|
||||
CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']);
|
||||
$this->server->update(['hetzner_server_id' => 123]);
|
||||
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->deleteJson("/api/v1/servers/{$this->server->uuid}?{$query}")
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. Deleting the resources or the cloud server needs a token with the deploy permission.');
|
||||
|
||||
expect(Server::query()->whereKey($this->server->id)->exists())->toBeTrue();
|
||||
})->with(['force=true', 'delete_from_provider=true']);
|
||||
|
||||
test('write-only token can delete an empty server', function () {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->deleteJson("/api/v1/servers/{$this->server->uuid}")
|
||||
->assertOk();
|
||||
|
||||
expect(Server::query()->whereKey($this->server->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('write and deploy token can delete with force', function () {
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true")
|
||||
->assertOk();
|
||||
|
||||
expect(Server::query()->whereKey($this->server->id)->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destination create', function () {
|
||||
test('rejects Docker built-in network names', function (string $network) {
|
||||
Process::fake();
|
||||
$countBefore = StandaloneDocker::query()->count();
|
||||
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", ['network' => $network])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('errors.network.0', 'The network name is reserved by Docker. Use a different name.');
|
||||
|
||||
expect(StandaloneDocker::query()->count())->toBe($countBefore);
|
||||
Process::assertNothingRan();
|
||||
})->with(['bridge', 'host', 'none', 'Bridge']);
|
||||
|
||||
test('rejects a network that already exists on the server', function () {
|
||||
Process::fake(fn () => Process::result(output: "bridge\nhost\nnone\ncoolify\nprivate-db-net\n"));
|
||||
$countBefore = StandaloneDocker::query()->count();
|
||||
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", ['network' => 'private-db-net'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('errors.network.0', 'A Docker network with this name already exists on the server. Use a new network name.');
|
||||
|
||||
expect(StandaloneDocker::query()->count())->toBe($countBefore);
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'docker network ls'));
|
||||
});
|
||||
|
||||
test('rejects the destination when the server networks cannot be read', function () {
|
||||
Process::fake(fn () => Process::result(errorOutput: 'Permission denied', exitCode: 1));
|
||||
$countBefore = StandaloneDocker::query()->count();
|
||||
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", ['network' => 'brand-new-net'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Could not read the Docker networks on the server. Check that the server is reachable and try again.');
|
||||
|
||||
expect(StandaloneDocker::query()->count())->toBe($countBefore);
|
||||
});
|
||||
|
||||
test('creates a destination with a new network', function () {
|
||||
Process::fake(fn () => Process::result(output: "bridge\nhost\nnone\ncoolify\n"));
|
||||
|
||||
$this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", ['network' => 'brand-new-net'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('network', 'brand-new-net');
|
||||
|
||||
expect(StandaloneDocker::query()->where('server_id', $this->server->id)->where('network', 'brand-new-net')->exists())->toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ beforeEach(function () {
|
||||
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
||||
$this->server->refresh();
|
||||
|
||||
$this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write']);
|
||||
$this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write', 'deploy']);
|
||||
$this->sensitiveToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
||||
});
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ test('PUT /api/v1/servers/{uuid}/proxy/configuration requires the deploy permiss
|
||||
'configuration' => base64_encode($configuration),
|
||||
])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy');
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. Coolify applies the proxy configuration on the server, so it needs a token with the deploy permission.');
|
||||
});
|
||||
|
||||
test('PUT /api/v1/servers/{uuid}/proxy/configuration saves with write and deploy permissions', function () {
|
||||
|
||||
@@ -206,13 +206,13 @@ describe('Log drains API', function () {
|
||||
expect($settings->{$field})->toBeNull();
|
||||
})->with(['logdrain_custom_config', 'logdrain_custom_config_parser']);
|
||||
|
||||
test('PATCH lets a write token enable the custom log drain with its saved config', function () {
|
||||
test('PATCH lets a write and deploy token enable the custom log drain with its saved config', function () {
|
||||
Queue::fake();
|
||||
$this->server->settings->update(['logdrain_custom_config' => "[OUTPUT]\n Name stdout"]);
|
||||
$writeOnlyToken = $this->user->createToken('server-subsystems-write', ['write'])->plainTextToken;
|
||||
$deployToken = $this->user->createToken('server-subsystems-deploy', ['write', 'deploy'])->plainTextToken;
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$writeOnlyToken,
|
||||
'Authorization' => 'Bearer '.$deployToken,
|
||||
'Accept' => 'application/json',
|
||||
])
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
Process::fake();
|
||||
config([
|
||||
'app.maintenance.store' => 'array',
|
||||
'cache.default' => 'array',
|
||||
'cache.stores.redis.driver' => 'array',
|
||||
]);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
function serverSubsystemsDeployHeaders(array $abilities): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.test()->user->createToken('server-subsystems', $abilities)->plainTextToken,
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
test('a write-only token cannot change a server subsystem that runs on the server', function (string $method, string $path, array $payload) {
|
||||
$this->withHeaders(serverSubsystemsDeployHeaders(['write']))
|
||||
->json($method, "/api/v1/servers/{$this->server->uuid}{$path}", $payload)
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy');
|
||||
|
||||
$settings = $this->server->settings->fresh();
|
||||
expect($settings->logdrain_axiom_dataset_name)->toBeNull()
|
||||
->and((bool) $settings->is_sentinel_debug_enabled)->toBeFalse();
|
||||
Queue::assertNothingPushed();
|
||||
Process::assertNothingRan();
|
||||
})->with([
|
||||
'log drains' => ['PATCH', '/log-drains', ['logdrain_axiom_dataset_name' => 'api-dataset']],
|
||||
'sentinel' => ['PATCH', '/sentinel', ['is_sentinel_debug_enabled' => true]],
|
||||
'docker cleanup run' => ['POST', '/docker-cleanup/run', []],
|
||||
'registry login' => ['POST', '/registries', ['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'secret']],
|
||||
]);
|
||||
|
||||
test('a write-only token cannot turn on unused volume deletion', function () {
|
||||
$this->server->settings->update(['delete_unused_volumes' => false]);
|
||||
|
||||
$this->withHeaders(serverSubsystemsDeployHeaders(['write']))
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", ['delete_unused_volumes' => true])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. Deleting unused volumes removes data on the server, so it needs a token with the deploy permission.');
|
||||
|
||||
expect((bool) $this->server->settings->fresh()->delete_unused_volumes)->toBeFalse();
|
||||
});
|
||||
|
||||
test('a write-only token can change docker cleanup settings that keep volumes', function (bool $volumesBefore, array $payload) {
|
||||
$this->server->settings->update(['delete_unused_volumes' => $volumesBefore]);
|
||||
|
||||
$this->withHeaders(serverSubsystemsDeployHeaders(['write']))
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", $payload)
|
||||
->assertOk();
|
||||
|
||||
$settings = $this->server->settings->fresh();
|
||||
foreach ($payload as $field => $value) {
|
||||
expect($settings->{$field})->toEqual($value);
|
||||
}
|
||||
})->with([
|
||||
'unused networks' => [false, ['delete_unused_networks' => true, 'docker_cleanup_threshold' => 50]],
|
||||
'turn off volume deletion' => [true, ['delete_unused_volumes' => false]],
|
||||
'volume deletion already on' => [true, ['delete_unused_volumes' => true]],
|
||||
]);
|
||||
|
||||
test('a write and deploy token can turn on unused volume deletion', function (array $abilities) {
|
||||
$this->server->settings->update(['delete_unused_volumes' => false]);
|
||||
|
||||
$this->withHeaders(serverSubsystemsDeployHeaders($abilities))
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", ['delete_unused_volumes' => true])
|
||||
->assertOk()
|
||||
->assertJsonPath('delete_unused_volumes', true);
|
||||
|
||||
expect((bool) $this->server->settings->fresh()->delete_unused_volumes)->toBeTrue();
|
||||
})->with([
|
||||
'write and deploy' => [['write', 'deploy']],
|
||||
'root' => [['root']],
|
||||
]);
|
||||
@@ -38,6 +38,8 @@ it('validates without installing by default', function () {
|
||||
});
|
||||
|
||||
it('validates and installs when explicitly requested', function () {
|
||||
$this->token = $this->user->createToken('server-validation-install', ['write', 'deploy'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(serverValidationHeaders())
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => true]);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$token = $this->user->createToken('service-delete-message', ['write']);
|
||||
$token = $this->user->createToken('service-delete-message', ['write', 'deploy']);
|
||||
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
|
||||
$this->bearerToken = $token->plainTextToken;
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
Queue::fake();
|
||||
Process::fake(fn () => Process::result(output: 'OK'));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
||||
]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first();
|
||||
$destinationAttributes = [
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
|
||||
$application = Application::factory()->create($destinationAttributes);
|
||||
$service = Service::factory()->create($destinationAttributes + ['server_id' => $server->id]);
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'web',
|
||||
'service_id' => $service->id,
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
$database = StandalonePostgresql::create($destinationAttributes + [
|
||||
'name' => 'storage-db',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
]);
|
||||
|
||||
$this->targets = [
|
||||
'applications' => ["/api/v1/applications/{$application->uuid}/storages", [], $application],
|
||||
'services' => ["/api/v1/services/{$service->uuid}/storages", ['resource_uuid' => $serviceApplication->uuid], $serviceApplication],
|
||||
'databases' => ["/api/v1/databases/{$database->uuid}/storages", [], $database],
|
||||
];
|
||||
});
|
||||
|
||||
function storageContentAbilityHeaders(User $user, int $teamId, array $abilities): array
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'storage-content-ability-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
|
||||
}
|
||||
|
||||
test('write-only token cannot create a file storage with content', function (string $type) {
|
||||
[$url, $payload] = $this->targets[$type];
|
||||
|
||||
$this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;'])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.');
|
||||
|
||||
expect(LocalFileVolume::query()->count())->toBe(0);
|
||||
Queue::assertNotPushed(ServerStorageSaveJob::class);
|
||||
})->with(['applications', 'services', 'databases']);
|
||||
|
||||
test('write-only token can create a directory storage', function (string $type) {
|
||||
[$url, $payload, $resource] = $this->targets[$type];
|
||||
|
||||
$this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => 'data', 'mount_path' => '/data'])
|
||||
->assertCreated();
|
||||
|
||||
expect($resource->fileStorages()->where('is_directory', true)->count())->toBe(1);
|
||||
})->with(['applications', 'services', 'databases']);
|
||||
|
||||
test('write and deploy token creates a file storage with content', function (string $type) {
|
||||
[$url, $payload, $resource] = $this->targets[$type];
|
||||
|
||||
$this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;'])
|
||||
->assertCreated();
|
||||
|
||||
expect($resource->fileStorages()->where('mount_path', '/etc/app.conf')->value('content'))->toBe('listen 8080;');
|
||||
Queue::assertPushed(ServerStorageSaveJob::class);
|
||||
})->with(['applications', 'services', 'databases']);
|
||||
@@ -144,3 +144,42 @@ test('owner can deselect root and falls back to read permission', function () {
|
||||
->set('permissions', [])
|
||||
->assertSet('permissions', ['read']);
|
||||
});
|
||||
|
||||
test('deploy permission can be combined with write permission', function (array $steps) {
|
||||
$owner = User::factory()->create();
|
||||
$this->team->members()->attach($owner->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component = Livewire::test(ApiTokens::class)->set('permissions', ['read']);
|
||||
|
||||
foreach ($steps as $permission) {
|
||||
$component->set('permissions.'.count($component->get('permissions')), $permission);
|
||||
}
|
||||
|
||||
$component->assertSet('permissions', ['deploy', 'read', 'write']);
|
||||
})->with([
|
||||
'write then deploy' => [['write', 'deploy']],
|
||||
'deploy then write' => [['deploy', 'write']],
|
||||
]);
|
||||
|
||||
test('owner can create token with read, write, and deploy permissions', function () {
|
||||
$owner = User::factory()->create();
|
||||
$this->team->members()->attach($owner->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'deploy-write-token')
|
||||
->set('expiresInDays', 30)
|
||||
->set('permissions', ['read', 'write', 'deploy'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $owner->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->abilities)->toEqualCanonicalizing(['read', 'write', 'deploy']);
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ test('the application parser stores a content volume at its host path and Coolif
|
||||
contentHostPathAssertWritten($writePath, $content, $user);
|
||||
})->with(contentHostPathTargets())->with(['root', 'coolify']);
|
||||
|
||||
test('loading files for a service writes content at its host path', function (string $target, string $content, string $user) {
|
||||
test('loading files for a service writes content inside the resource directory', function (string $target, string $content, string $user) {
|
||||
contentHostPathUseServerUser($user);
|
||||
$service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE);
|
||||
serviceParser($service);
|
||||
@@ -232,7 +232,26 @@ test('loading files for a service writes content at its host path', function (st
|
||||
$base64 = base64_encode($content);
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p -- \"$({$sudo}dirname -- {$escapedPath})\""));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee -- {$escapedPath}"));
|
||||
})->with(contentHostPathTargets())->with(['root', 'coolify']);
|
||||
})->with([
|
||||
'home directory' => ['/etc/b.conf', 'b'],
|
||||
'bare relative path' => ['/etc/c.conf', 'c'],
|
||||
])->with(['root', 'coolify']);
|
||||
|
||||
// A token with only the write permission can save a Compose file. Content outside the resource
|
||||
// directory is written when the resource is deployed or started, not when its files are loaded.
|
||||
test('loading files for a service does not write content outside the resource directory', function (string $target, string $content) {
|
||||
$service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE);
|
||||
serviceParser($service);
|
||||
$fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole();
|
||||
|
||||
contentHostPathFakeServer();
|
||||
getFilesystemVolumesFromServer($fileVolume->resource, true);
|
||||
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode($content)));
|
||||
})->with([
|
||||
'absolute host file' => ['/etc/a.conf', 'a'],
|
||||
'parent directory' => ['/etc/d.conf', 'd'],
|
||||
]);
|
||||
|
||||
test('a quote in a content path is shell-escaped in every write command', function (string $user) {
|
||||
contentHostPathUseServerUser($user);
|
||||
@@ -245,11 +264,9 @@ test('a quote in a content path is shell-escaped in every write command', functi
|
||||
|
||||
contentHostPathFakeServer();
|
||||
$fileVolume->saveStorageOnServer();
|
||||
getFilesystemVolumesFromServer($fileVolume->resource, true);
|
||||
|
||||
$sudo = $user === 'root' ? '' : 'sudo ';
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}tee '/etc/my'\\''app/app.conf' > /dev/null"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}tee -- '/etc/my'\\''app/app.conf'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p '/etc/my'\\''app'"));
|
||||
})->with(['root', 'coolify']);
|
||||
|
||||
@@ -410,3 +427,15 @@ test('the service API rejects shell injection in a content volume source', funct
|
||||
expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE)
|
||||
->and(LocalFileVolume::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('loading files for a service does not create directories outside the resource directory', function () {
|
||||
$compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: /etc/cron.d/coolify-test\n target: /data\n";
|
||||
$service = contentHostPathService($compose);
|
||||
serviceParser($service);
|
||||
$fileVolume = LocalFileVolume::query()->sole();
|
||||
|
||||
contentHostPathFakeServer();
|
||||
getFilesystemVolumesFromServer($fileVolume->resource, true);
|
||||
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, '/etc/cron.d/coolify-test') && str_contains($process->command, 'mkdir'));
|
||||
});
|
||||
|
||||
@@ -587,6 +587,8 @@ function dockerCleanupRunImageCommand(string $command, array $images): string
|
||||
file_put_contents("$bin/images", implode("\n", $images)."\n");
|
||||
file_put_contents("$bin/docker", "#!/bin/sh\nif [ \"\$1\" = images ]; then cat $bin/images; elif [ \"\$1\" = rmi ]; then echo \"\$@\"; fi\n");
|
||||
chmod("$bin/docker", 0755);
|
||||
file_put_contents("$bin/sudo", "#!/bin/sh\nexec \"\$@\"\n");
|
||||
chmod("$bin/sudo", 0755);
|
||||
|
||||
$output = shell_exec('PATH='.escapeshellarg("$bin:".getenv('PATH')).' /bin/sh -c '.escapeshellarg($command));
|
||||
|
||||
@@ -632,3 +634,21 @@ it('removes unused images pulled by digest but keeps application images', functi
|
||||
expect($command)->toContain('docker images --digests')
|
||||
->and(explode("\n", $output))->toBe(['rmi gtstef/filebrowser@sha256:abc', 'rmi postgres:16']);
|
||||
});
|
||||
|
||||
it('keeps an application image name with a single quote inside the grep pattern', function (bool $nonRootUser) {
|
||||
$marker = sys_get_temp_dir().'/docker-cleanup-injected-'.bin2hex(random_bytes(4));
|
||||
$imageName = "evil'; touch {$marker}; echo '";
|
||||
|
||||
$command = (new ReflectionMethod(CleanupDocker::class, 'buildImagePruneCommand'))->invoke(new CleanupDocker, collect([$imageName, 'app-uuid']), '1.0.17');
|
||||
if ($nonRootUser) {
|
||||
$command = implode("\n", parseCommandsByLineForSudo(collect([$command]), new Server(['user' => 'deploy'])));
|
||||
}
|
||||
|
||||
$output = dockerCleanupRunImageCommand($command, [
|
||||
'app-uuid:v1',
|
||||
'postgres:16',
|
||||
]);
|
||||
|
||||
expect(file_exists($marker))->toBeFalse()
|
||||
->and(explode("\n", $output))->toBe(['rmi postgres:16']);
|
||||
})->with(['root user' => false, 'non-root user' => true]);
|
||||
|
||||
@@ -127,16 +127,16 @@ describe('custom log drain API', function () {
|
||||
'parser only' => [['logdrain_custom_config_parser' => SAFE_CUSTOM_LOG_DRAIN_PARSERS]],
|
||||
]);
|
||||
|
||||
test('a write-only token can still change the built-in log drains', function () {
|
||||
test('a write-only token cannot change the built-in log drains either', function () {
|
||||
Queue::fake();
|
||||
$token = $this->user->createToken('write-only', ['write'])->plainTextToken;
|
||||
|
||||
customLogDrainPatch($token, $this->server->uuid, [
|
||||
'logdrain_axiom_dataset_name' => 'api-dataset',
|
||||
'logdrain_axiom_api_key' => 'axiom-key-123',
|
||||
])->assertOk();
|
||||
])->assertForbidden();
|
||||
|
||||
expect($this->server->settings->fresh()->logdrain_axiom_dataset_name)->toBe('api-dataset');
|
||||
expect($this->server->settings->fresh()->logdrain_axiom_dataset_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('a deploy token can save a safe custom log drain', function () {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Storage::fake('ssh-keys');
|
||||
Storage::fake('ssh-mux');
|
||||
config(['constants.ssh.mux_enabled' => false]);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
});
|
||||
|
||||
test('a server name with a line break cannot add a section to the fluent-bit config', function (array $settings) {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'name' => "prod\n[OUTPUT]\n Name stdout\r\n Match *",
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
|
||||
]);
|
||||
$server->settings()->update($settings);
|
||||
|
||||
$commands = new Collection;
|
||||
Process::fake(function ($process) use ($commands) {
|
||||
$commands->push($process->command);
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
StartLogDrain::run($server->fresh());
|
||||
|
||||
preg_match("/echo '([A-Za-z0-9+\\/=]+)' \\| base64 -d \\| tee \\S+\\/fluent-bit\\.conf/", $commands->implode("\n"), $matches);
|
||||
$config = base64_decode($matches[1]);
|
||||
|
||||
expect($config)->toContain("Set coolify.server_name prod [OUTPUT] Name stdout Match *\n")
|
||||
->and(preg_match_all('/^\[OUTPUT\]/m', $config))->toBe(1)
|
||||
->and($config)->not->toMatch('/^\s*Name stdout/m');
|
||||
})->with([
|
||||
'new relic' => [['is_logdrain_newrelic_enabled' => true, 'logdrain_newrelic_license_key' => 'key', 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com/log/v1']],
|
||||
'axiom' => [['is_logdrain_axiom_enabled' => true, 'logdrain_axiom_dataset_name' => 'dataset', 'logdrain_axiom_api_key' => 'key']],
|
||||
]);
|
||||
@@ -59,7 +59,7 @@ afterEach(function () {
|
||||
|
||||
test('deleting a server fails its queued deployments', function (string $path) {
|
||||
if ($path === 'api') {
|
||||
$token = $this->user->createToken('server-delete', ['write'])->plainTextToken;
|
||||
$token = $this->user->createToken('server-delete', ['write', 'deploy'])->plainTextToken;
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true")
|
||||
->assertOk();
|
||||
|
||||
@@ -97,3 +97,55 @@ it('writes a new file mount immediately when there is no transaction', function
|
||||
expect($this->processedSaves)->toBe(1);
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, base64_encode('KEY=value')));
|
||||
});
|
||||
|
||||
it('does not write compose content outside the resource directory before a deployment', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /etc/cron.d/coolify-test\n target: /x\n content: 'outside-content'\n",
|
||||
]);
|
||||
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => '/etc/cron.d/coolify-test',
|
||||
'mount_path' => '/x',
|
||||
'content' => 'outside-content',
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
expect($this->processedSaves)->toBe(0);
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode('outside-content')));
|
||||
});
|
||||
|
||||
it('writes compose content inside the resource directory immediately', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: ./config.env\n target: /app/config.env\n content: 'inside-content'\n",
|
||||
]);
|
||||
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => application_configuration_dir().'/'.$this->application->uuid.'/config.env',
|
||||
'mount_path' => '/app/config.env',
|
||||
'content' => 'inside-content',
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
expect($this->processedSaves)->toBe(1);
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, base64_encode('inside-content')));
|
||||
});
|
||||
|
||||
it('does not write a mount outside the resource directory before a deployment', function () {
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => '/opt/coolify-outside.conf',
|
||||
'mount_path' => '/app/outside.conf',
|
||||
'content' => 'outside-without-compose',
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
expect($this->processedSaves)->toBe(0);
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode('outside-without-compose')));
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ test('preview imports reject another team UUID and roll back all imported record
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => $role]);
|
||||
session(['currentTeam' => $team]);
|
||||
$token = $user->createToken('preview-import', ['write']);
|
||||
$token = $user->createToken('preview-import', ['write', 'deploy']);
|
||||
$token->accessToken->forceFill(['team_id' => $team->id])->save();
|
||||
$counts = [Server::count(), Application::withTrashed()->count(), PrivateKey::count(), Project::count()];
|
||||
|
||||
|
||||
@@ -298,8 +298,8 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
|
||||
|
||||
test('hides created file storage content for write tokens and reveals it for sensitive tokens', function () {
|
||||
$app = createTestApplication($this);
|
||||
$writeToken = createStorageApiToken($this, ['write']);
|
||||
$sensitiveToken = createStorageApiToken($this, ['write', 'read:sensitive']);
|
||||
$writeToken = createStorageApiToken($this, ['write', 'deploy']);
|
||||
$sensitiveToken = createStorageApiToken($this, ['write', 'deploy', 'read:sensitive']);
|
||||
|
||||
$writeResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$writeToken,
|
||||
|
||||
@@ -411,10 +411,10 @@ test('a write token creates mounts inside the resource directory', function (str
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => 'data/zz', 'mount_path' => '/data'])
|
||||
->assertCreated();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => $workdir.'/config/app.conf', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;'])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => $workdir.'/config/app.conf', 'mount_path' => '/etc/app.conf'])
|
||||
->assertCreated();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/other.conf', 'content' => 'listen 8081;'])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/other.conf'])
|
||||
->assertCreated();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'is_host_file' => true, 'fs_path' => '/etc/hosts', 'mount_path' => '/etc/hosts'])
|
||||
|
||||
@@ -168,7 +168,7 @@ it('quotes literal file-storage paths and safely expands persisted expressions',
|
||||
]);
|
||||
|
||||
$literal = Mockery::mock(LocalFileVolume::class)->makePartial();
|
||||
$literal->fs_path = '/data/my files/config.yaml';
|
||||
$literal->fs_path = '/data/application/my files/config.yaml';
|
||||
$literal->is_directory = true;
|
||||
$literal->shouldReceive('save')->once();
|
||||
$file = Mockery::mock(LocalFileVolume::class)->makePartial();
|
||||
@@ -190,6 +190,8 @@ it('quotes literal file-storage paths and safely expands persisted expressions',
|
||||
$application->shouldReceive('fileStorages')->once()->andReturn($fileStorages);
|
||||
$application->setRelation('destination', (object) ['server' => $server]);
|
||||
$file->setRelation('resource', $application);
|
||||
$literal->setRelation('resource', $application);
|
||||
$expression->setRelation('resource', $application);
|
||||
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains($process->command, 'readlink -f') => 'OK',
|
||||
@@ -198,8 +200,8 @@ it('quotes literal file-storage paths and safely expands persisted expressions',
|
||||
}));
|
||||
getFilesystemVolumesFromServer($application, true);
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "test -f '/data/my files/config.yaml'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p -- '/data/my files/config.yaml'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "test -f '/data/application/my files/config.yaml'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p -- '/data/application/my files/config.yaml'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "dirname -- '/data/application/my files/settings.json'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "tee -- '/data/application/my files/settings.json'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, '${DATA_PATH:-/srv/app/config.yaml}'));
|
||||
|
||||
Reference in New Issue
Block a user