Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react

This commit is contained in:
Andras Bacsai
2026-07-18 15:57:26 +02:00
139 changed files with 8677 additions and 545 deletions
@@ -35,6 +35,36 @@ class ApplicationsController extends Controller
{
use Concerns\HandlesTagsApi;
private const APPLICATION_SETTING_FIELDS = [
'is_git_submodules_enabled',
'is_git_lfs_enabled',
'is_git_shallow_clone_enabled',
'disable_build_cache',
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'is_env_sorting_enabled',
'is_pr_deployments_public_enabled',
'stop_grace_period',
'docker_images_to_keep',
'is_gzip_enabled',
'is_stripprefix_enabled',
'is_raw_compose_deployment_enabled',
];
private const BOOLEAN_APPLICATION_SETTING_FIELDS = [
'is_git_submodules_enabled',
'is_git_lfs_enabled',
'is_git_shallow_clone_enabled',
'disable_build_cache',
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'is_env_sorting_enabled',
'is_pr_deployments_public_enabled',
'is_gzip_enabled',
'is_stripprefix_enabled',
'is_raw_compose_deployment_enabled',
];
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
@@ -87,9 +117,48 @@ class ApplicationsController extends Controller
$application->makeHidden(['value', 'real_value']);
}
if ($application->relationLoaded('settings')) {
$application->settings?->makeHidden(['id', 'application_id', 'created_at', 'updated_at']);
}
return serializeApiResponse($application);
}
private function applicationSettingsFromRequest(Request $request): array
{
$settings = [];
foreach (self::APPLICATION_SETTING_FIELDS as $field) {
if (! array_key_exists($field, $request->all())) {
continue;
}
$settings[$field] = in_array($field, self::BOOLEAN_APPLICATION_SETTING_FIELDS, true)
? $request->boolean($field)
: $request->input($field);
}
return $settings;
}
private function applyApplicationSettings(Application $application, array $settings): void
{
if ($settings === []) {
return;
}
$regenerateLabels = ! $application->wasRecentlyCreated
&& $application->settings->is_container_label_readonly_enabled
&& (array_key_exists('is_gzip_enabled', $settings) || array_key_exists('is_stripprefix_enabled', $settings));
$application->settings->fill($settings)->save();
if ($regenerateLabels) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
}
/**
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability.
@@ -285,6 +354,20 @@ class ApplicationsController extends Controller
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@@ -453,6 +536,20 @@ class ApplicationsController extends Controller
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@@ -621,6 +718,20 @@ class ApplicationsController extends Controller
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@@ -761,6 +872,20 @@ class ApplicationsController extends Controller
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@@ -897,6 +1022,20 @@ class ApplicationsController extends Controller
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@@ -981,7 +1120,7 @@ class ApplicationsController extends Controller
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1036,6 +1175,7 @@ class ApplicationsController extends Controller
$instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid;
$useBuildServer = $request->use_build_server;
$useBuildSecrets = $request->use_build_secrets;
$isStatic = $request->is_static;
$isSpa = $request->is_spa;
$isAutoDeployEnabled = $request->is_auto_deploy_enabled;
@@ -1045,6 +1185,19 @@ class ApplicationsController extends Controller
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false);
$applicationSettings = $this->applicationSettingsFromRequest($request);
$requestedBuildPack = in_array($type, ['public', 'private-gh-app', 'private-deploy-key'], true)
? $request->input('build_pack')
: $type;
if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'is_raw_compose_deployment_enabled' => 'Raw compose deployment can only be enabled for Docker Compose applications.',
],
], 422);
}
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@@ -1084,6 +1237,12 @@ class ApplicationsController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
@@ -1232,6 +1391,7 @@ class ApplicationsController extends Controller
$application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id;
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
if (isset($isStatic)) {
$application->settings->is_static = $isStatic;
$application->settings->save();
@@ -1260,6 +1420,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@@ -1478,6 +1642,7 @@ class ApplicationsController extends Controller
$application->repository_project_id = $repository_project_id;
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@@ -1512,6 +1677,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@@ -1694,6 +1863,7 @@ class ApplicationsController extends Controller
$application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id;
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@@ -1728,6 +1898,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@@ -1837,6 +2011,7 @@ class ApplicationsController extends Controller
$application->git_repository = 'coollabsio/coolify';
$application->git_branch = 'main';
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@@ -1859,6 +2034,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@@ -1963,6 +2142,7 @@ class ApplicationsController extends Controller
$application->git_repository = 'coollabsio/coolify';
$application->git_branch = 'main';
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@@ -1985,6 +2165,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@@ -2090,7 +2274,7 @@ class ApplicationsController extends Controller
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->with('settings')->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
@@ -2405,11 +2589,24 @@ class ApplicationsController extends Controller
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include source commit information in the build. Default is false.'],
],
)
),
@@ -2495,7 +2692,7 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'include_source_commit_in_build'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS];
$validationRules = [
'name' => 'string|max:255',
@@ -2574,6 +2771,17 @@ class ApplicationsController extends Controller
], 422);
}
$applicationSettings = $this->applicationSettingsFromRequest($request);
$requestedBuildPack = $request->input('build_pack', $application->build_pack);
if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'is_raw_compose_deployment_enabled' => 'Raw compose deployment can only be enabled for Docker Compose applications.',
],
], 422);
}
if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) {
if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) {
$validationErrors = [];
@@ -2728,6 +2936,7 @@ class ApplicationsController extends Controller
$isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled;
$connectToDockerNetwork = $request->connect_to_docker_network;
$useBuildServer = $request->use_build_server;
$useBuildSecrets = $request->use_build_secrets;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled');
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled');
$includeSourceCommitInBuild = $request->boolean('include_source_commit_in_build');
@@ -2735,6 +2944,10 @@ class ApplicationsController extends Controller
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isStatic)) {
$application->settings->is_static = $isStatic;
@@ -2778,6 +2991,7 @@ class ApplicationsController extends Controller
$application->settings->include_source_commit_in_build = $includeSourceCommitInBuild;
$application->settings->save();
}
$this->applyApplicationSettings($application, $applicationSettings);
removeUnnecessaryFieldsFromRequest($request);
$data = $request->only($allowedFields);
@@ -4023,7 +4237,7 @@ class ApplicationsController extends Controller
),
]
)]
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
public function move_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -850,6 +850,12 @@ class DatabasesController extends Controller
$this->authorize('manageBackups', $database);
if (! $database->isBackupSolutionAvailable()) {
return response()->json([
'message' => 'Scheduled backups are not supported for this database type.',
], 422);
}
// Validate frequency is a valid cron expression
$isValid = validate_cron_expression($request->frequency);
if (! $isValid) {
@@ -915,6 +921,8 @@ class DatabasesController extends Controller
$backupData['databases_to_backup'] = $database->mysql_database;
} elseif ($database->type() === 'standalone-mariadb') {
$backupData['databases_to_backup'] = $database->mariadb_database;
} elseif ($database->type() === 'standalone-clickhouse') {
$backupData['databases_to_backup'] = $database->clickhouse_db;
}
}
@@ -1805,6 +1813,12 @@ class DatabasesController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
@@ -3016,7 +3030,7 @@ class DatabasesController extends Controller
),
]
)]
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
public function move_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -10,6 +10,7 @@ use App\Models\SwarmDocker;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class DestinationsController extends Controller
{
@@ -59,6 +60,22 @@ class DestinationsController extends Controller
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
}
#[OA\Get(
summary: 'List destinations',
description: 'List all Docker network destinations for the authenticated team.',
path: '/destinations',
operationId: 'list-destinations',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
responses: [
new OA\Response(
response: 200,
description: 'Destinations for the authenticated team.',
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
],
)]
public function index(Request $request): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -74,6 +91,26 @@ class DestinationsController extends Controller
);
}
#[OA\Get(
summary: 'List destinations by server',
description: 'List Docker network destinations attached to a server owned by the authenticated team.',
path: '/servers/{server_uuid}/destinations',
operationId: 'list-server-destinations',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destinations attached to the server.',
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function index_by_server(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -89,6 +126,26 @@ class DestinationsController extends Controller
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
}
#[OA\Get(
summary: 'Get destination',
description: 'Get a Docker network destination by UUID.',
path: '/destinations/{uuid}',
operationId: 'get-destination-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destination details.',
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -100,6 +157,40 @@ class DestinationsController extends Controller
return response()->json($this->transform($destination));
}
#[OA\Post(
summary: 'Create destination',
description: 'Create a Docker network destination on a server owned by the authenticated team.',
path: '/servers/{server_uuid}/destinations',
operationId: 'create-server-destination',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
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: 'type', type: 'string', enum: ['standalone', 'swarm']),
],
type: 'object',
),
),
responses: [
new OA\Response(
response: 201,
description: 'Destination created.',
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
),
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'),
],
)]
public function create(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -183,6 +274,32 @@ class DestinationsController extends Controller
|| in_array($driverCode, ['19', '1062', '2067'], true);
}
#[OA\Delete(
summary: 'Delete destination',
description: 'Delete an unused Docker network destination.',
path: '/destinations/{uuid}',
operationId: 'delete-destination-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destination deleted.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Deleted.'),
],
type: 'object',
),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'Destination has attached resources.'),
],
)]
public function delete(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -15,6 +15,7 @@ use App\Rules\ValidHostname;
use App\Services\DigitalOceanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
class DigitalOceanController extends Controller
@@ -283,6 +284,10 @@ class DigitalOceanController extends Controller
return response()->json(['message' => 'Private key not found.'], 404);
}
$digitalOceanService = null;
$dropletId = null;
$server = null;
try {
$digitalOceanService = new DigitalOceanService($token->token);
$sshKeyId = $this->getOrCreateSshKey($digitalOceanService, $privateKey);
@@ -309,29 +314,41 @@ class DigitalOceanController extends Controller
$droplet = $digitalOceanService->createDroplet($params);
$dropletId = (int) $droplet['id'];
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6);
if (! $ipAddress) {
throw new \Exception('No public IP address available for the new droplet.');
$server = DB::transaction(function () use ($normalizedServerName, $teamId, $privateKey, $token, $dropletId, $droplet): Server {
$server = Server::create([
'name' => $normalizedServerName,
'ip' => Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'digitalocean_droplet_id' => $dropletId,
'digitalocean_droplet_status' => $droplet['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return $server;
});
try {
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6);
if ($ipAddress) {
$server->update([
'ip' => $ipAddress,
'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
]);
}
} catch (\Throwable $e) {
report($e);
}
$server = Server::create([
'name' => $normalizedServerName,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'digitalocean_droplet_id' => $dropletId,
'digitalocean_droplet_status' => $droplet['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
@@ -341,15 +358,17 @@ class DigitalOceanController extends Controller
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'digitalocean_droplet_id' => $dropletId,
'ip' => $ipAddress,
'ip' => $server->ip,
]);
return response()->json([
'uuid' => $server->uuid,
'digitalocean_droplet_id' => $dropletId,
'ip' => $ipAddress,
'ip' => $server->ip,
])->setStatusCode(201);
} catch (RateLimitException $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
$response = response()->json(['message' => $e->getMessage()], 429);
if ($e->retryAfter !== null) {
$response->header('Retry-After', $e->retryAfter);
@@ -357,6 +376,8 @@ class DigitalOceanController extends Controller
return $response;
} catch (\Throwable $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
logger()->error('Failed to create DigitalOcean server', [
'error' => $e->getMessage(),
]);
@@ -365,6 +386,19 @@ class DigitalOceanController extends Controller
}
}
private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void
{
if (! $digitalOceanService || ! $dropletId || $server) {
return;
}
try {
$digitalOceanService->deleteDroplet($dropletId);
} catch (\Throwable $e) {
report($e);
}
}
private function getOrCreateSshKey(DigitalOceanService $digitalOceanService, PrivateKey $privateKey): int
{
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
@@ -143,8 +143,9 @@ class SentinelController extends Controller
* health checks can flap between starting/healthy/unhealthy while the
* container lifecycle state remains unchanged. Both would otherwise defeat
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
* The force window still refreshes full state periodically. Sorted by name
* so container ordering from Sentinel does not affect the hash.
* The snapshot completeness flag is included so a complete snapshot always
* dispatches after a partial snapshot. Sorted by name so container ordering
* from Sentinel does not affect the hash.
*/
private function containerStateHash(array $data): string
{
@@ -157,6 +158,14 @@ class SentinelController extends Controller
->values()
->all();
return hash('xxh128', json_encode($containers));
return hash('xxh128', json_encode([
'snapshot_complete' => $this->isCompleteSnapshot($data),
'containers' => $containers,
]));
}
private function isCompleteSnapshot(array $data): bool
{
return data_get($data, 'snapshot.complete', true) !== false;
}
}
@@ -736,6 +736,13 @@ class ServersController extends Controller
], 422);
}
if ($request->boolean('is_build_server') && ! $server->isBuildServer() && ! $server->isEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_build_server' => ['A server with existing resources cannot be configured as a build server.']],
], 422);
}
$server->update($updateFields);
if ($request->has('is_build_server')) {
$server->settings()->update([
@@ -424,6 +424,33 @@ class ServiceApplicationsController extends Controller
),
]
)]
#[OA\Post(
summary: 'Get service application logs',
description: 'Get Docker logs for a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/logs',
operationId: 'post-service-application-logs-by-service-and-app-uuid',
security: [['bearerAuth' => []]],
tags: ['Service applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
],
responses: [
new OA\Response(
response: 200,
description: 'Logs.',
content: new OA\JsonContent(
type: 'object',
properties: [new OA\Property(property: 'logs', type: 'string')],
),
),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function logs_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
@@ -463,7 +490,7 @@ class ServiceApplicationsController extends Controller
], 400);
}
$lines = (int) ($request->query('lines', 100) ?: 100);
$lines = normalizeLogLines($request->query('lines'));
$logs = getContainerLogs($server, $containerName, $lines);
return response()->json([
@@ -540,6 +567,34 @@ class ServiceApplicationsController extends Controller
),
]
)]
#[OA\Post(
summary: 'Start or redeploy service application container',
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
path: '/services/{uuid}/applications/{app_uuid}/start',
operationId: 'post-start-service-application-by-service-and-app-uuid',
security: [['bearerAuth' => []]],
tags: ['Service applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
],
responses: [
new OA\Response(
response: 200,
description: 'Deploy request queued.',
content: new OA\JsonContent(
type: 'object',
properties: [new OA\Property(property: 'message', type: 'string')],
),
),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function action_start(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
@@ -635,6 +690,32 @@ class ServiceApplicationsController extends Controller
),
]
)]
#[OA\Post(
summary: 'Restart service application container',
description: 'Restarts a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/restart',
operationId: 'post-restart-service-application-by-service-and-app-uuid',
security: [['bearerAuth' => []]],
tags: ['Service applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Restart queued.',
content: new OA\JsonContent(
type: 'object',
properties: [new OA\Property(property: 'message', type: 'string')],
),
),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function action_restart(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
@@ -727,6 +808,32 @@ class ServiceApplicationsController extends Controller
),
]
)]
#[OA\Post(
summary: 'Stop service application container',
description: 'Stops a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/stop',
operationId: 'post-stop-service-application-by-service-and-app-uuid',
security: [['bearerAuth' => []]],
tags: ['Service applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Stop queued.',
content: new OA\JsonContent(
type: 'object',
properties: [new OA\Property(property: 'message', type: 'string')],
),
),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function action_stop(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
@@ -0,0 +1,452 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
class ServiceDatabasesController extends Controller
{
private function removeSensitiveData(ServiceDatabase $serviceDatabase): array
{
$serviceDatabase->makeHidden([
'id',
'service',
'service_id',
'resourceable',
'resourceable_id',
'resourceable_type',
]);
$serialized = serializeApiResponse($serviceDatabase);
if ($serialized instanceof Collection) {
return $serialized->all();
}
return (array) $serialized;
}
private function resolveService(Request $request, int $teamId): ?Service
{
return Service::whereRelation('environment.project.team', 'id', $teamId)
->whereUuid($request->route('uuid'))
->first();
}
private function resolveServiceDatabase(Request $request, Service $service): ?ServiceDatabase
{
return $service->databases()
->where('uuid', $request->route('database_uuid'))
->with(['service.destination.server'])
->first();
}
private function swarmNotSupportedResponse(): JsonResponse
{
return response()->json([
'message' => 'This operation is not supported for Swarm servers yet.',
], 501);
}
#[OA\Get(
summary: 'List service databases',
description: 'List compose databases for a single service.',
path: '/services/{uuid}/databases',
operationId: 'list-service-databases-by-service-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Service databases.', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'object'))),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('view', $service);
$databases = $service->databases()
->get()
->map(fn (ServiceDatabase $database) => $this->removeSensitiveData($database));
return response()->json($databases);
}
#[OA\Get(
summary: 'Get service database',
description: 'Get a compose database by service UUID and database UUID.',
path: '/services/{uuid}/databases/{database_uuid}',
operationId: 'get-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Service database.', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function show(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
if (! $serviceDatabase) {
return response()->json(['message' => 'Service database not found.'], 404);
}
$this->authorize('view', $serviceDatabase);
return response()->json($this->removeSensitiveData($serviceDatabase));
}
#[OA\Patch(
summary: 'Update service database',
description: 'Update mutable fields for a compose service database.',
path: '/services/{uuid}/databases/{database_uuid}',
operationId: 'patch-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'human_name', type: 'string', nullable: true),
new OA\Property(property: 'description', type: 'string', nullable: true),
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: 'public_port', type: 'integer', nullable: true, minimum: 1, maximum: 65535),
new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, minimum: 1),
],
additionalProperties: false,
)
),
responses: [
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: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function update(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$invalidRequest = validateIncomingRequest($request);
if ($invalidRequest instanceof JsonResponse) {
return $invalidRequest;
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
if (! $serviceDatabase) {
return response()->json(['message' => 'Service database not found.'], 404);
}
$this->authorize('update', $serviceDatabase);
$payload = $request->json()->all();
if (empty($payload)) {
$payload = $request->request->all();
}
$allowedFields = [
'human_name',
'description',
'image',
'exclude_from_status',
'is_log_drain_enabled',
'is_public',
'public_port',
'public_port_timeout',
];
$validator = Validator::make($payload, [
'human_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'image' => 'sometimes|string',
'exclude_from_status' => 'sometimes|boolean',
'is_log_drain_enabled' => 'sometimes|boolean',
'is_public' => 'sometimes|boolean',
'public_port' => 'nullable|integer|min:1|max:65535',
'public_port_timeout' => 'nullable|integer|min:1',
]);
$extraFields = array_diff(array_keys($payload), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$server = $serviceDatabase->service->destination->server;
if (($payload['is_log_drain_enabled'] ?? false) && ! $server->isLogDrainEnabled()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.']],
], 422);
}
$isPublic = $payload['is_public'] ?? $serviceDatabase->is_public;
$publicPort = $payload['public_port'] ?? $serviceDatabase->public_port;
if ($isPublic && ! $publicPort) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['public_port' => ['A public port is required when the database is public.']],
], 422);
}
if ($isPublic && isPublicPortAlreadyUsed($server, $publicPort, $serviceDatabase->id)) {
return response()->json(['message' => 'Public port already used by another database.'], 400);
}
$shouldStartProxy = ($payload['is_public'] ?? null) === true && ! $serviceDatabase->is_public;
$shouldStopProxy = ($payload['is_public'] ?? null) === false && $serviceDatabase->is_public;
$serviceDatabase->fill($payload);
$serviceDatabase->save();
$serviceDatabase->refresh();
updateCompose($serviceDatabase);
if ($shouldStartProxy) {
StartDatabaseProxy::dispatch($serviceDatabase);
} elseif ($shouldStopProxy) {
StopDatabaseProxy::dispatch($serviceDatabase);
}
auditLog('api.service_database.updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_database_uuid' => $serviceDatabase->uuid,
'changed_fields' => array_keys($payload),
]);
return response()->json($this->removeSensitiveData($serviceDatabase));
}
#[OA\Get(
summary: 'Get service database logs',
description: 'Get Docker logs for a compose database container.',
path: '/services/{uuid}/databases/{database_uuid}/logs',
operationId: 'get-service-database-logs-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
],
responses: [
new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function logs(Request $request): JsonResponse
{
$resolved = $this->resolveDatabaseRequest($request, 'view');
if ($resolved instanceof JsonResponse) {
return $resolved;
}
[$serviceDatabase, $server] = $resolved;
$containerName = $serviceDatabase->name.'-'.$serviceDatabase->service->uuid;
if (getContainerStatus($server, $containerName) !== 'running') {
return response()->json(['message' => 'Service database container is not running.'], 400);
}
$lines = normalizeLogLines($request->query('lines'));
return response()->json([
'logs' => getContainerLogs($server, $containerName, $lines),
]);
}
#[OA\Post(
summary: 'Start or redeploy service database container',
description: 'Run docker compose up for a single compose database.',
path: '/services/{uuid}/databases/{database_uuid}/start',
operationId: 'start-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
],
responses: [
new OA\Response(response: 200, description: 'Deploy request queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function start(Request $request): JsonResponse
{
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
if ($resolved instanceof JsonResponse) {
return $resolved;
}
[$serviceDatabase] = $resolved;
DeployServiceApplication::dispatch(
$serviceDatabase,
$request->boolean('latest'),
$request->boolean('force'),
);
return response()->json(['message' => 'Service database deploy request queued.']);
}
#[OA\Post(
summary: 'Restart service database container',
description: 'Restart a compose database container.',
path: '/services/{uuid}/databases/{database_uuid}/restart',
operationId: 'restart-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Restart queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function restart(Request $request): JsonResponse
{
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
if ($resolved instanceof JsonResponse) {
return $resolved;
}
[$serviceDatabase] = $resolved;
RestartServiceApplication::dispatch($serviceDatabase);
return response()->json(['message' => 'Service database restart request queued.']);
}
#[OA\Post(
summary: 'Stop service database container',
description: 'Stop a compose database container.',
path: '/services/{uuid}/databases/{database_uuid}/stop',
operationId: 'stop-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Stop queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function stop(Request $request): JsonResponse
{
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
if ($resolved instanceof JsonResponse) {
return $resolved;
}
[$serviceDatabase] = $resolved;
StopServiceApplication::dispatch($serviceDatabase);
return response()->json(['message' => 'Service database stop request queued.']);
}
private function resolveDatabaseRequest(Request $request, string $ability): array|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
if (! $serviceDatabase) {
return response()->json(['message' => 'Service database not found.'], 404);
}
$this->authorize($ability, $serviceDatabase);
$server = $serviceDatabase->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json(['message' => 'Server is not functional.'], 400);
}
return [$serviceDatabase, $server];
}
}
@@ -444,6 +444,12 @@ class ServicesController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
@@ -501,7 +507,8 @@ class ServicesController extends Controller
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($servicePayload);
$service = new Service($servicePayload);
$service->save();
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
if ($request->has('is_container_label_escape_enabled')) {
@@ -639,6 +646,12 @@ class ServicesController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
@@ -1053,11 +1066,6 @@ class ServicesController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'The service name.'],
'description' => ['type' => 'string', 'description' => 'The service description.'],
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'environment_name' => ['type' => 'string', 'description' => 'The environment name.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the service should be deployed instantly.'],
'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'],
@@ -1942,7 +1950,7 @@ class ServicesController extends Controller
),
]
)]
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
public function move_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
+67 -29
View File
@@ -15,6 +15,7 @@ use App\Rules\ValidHostname;
use App\Services\VultrService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
class VultrController extends Controller
@@ -52,6 +53,8 @@ class VultrController extends Controller
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
return $token;
}
@@ -277,11 +280,17 @@ class VultrController extends Controller
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
$vultrService = null;
$vultrInstanceId = null;
$server = null;
try {
$vultrService = new VultrService($token->token);
$publicKey = $privateKey->getPublicKey();
@@ -313,33 +322,41 @@ class VultrController extends Controller
}
$vultrInstance = $vultrService->createInstance($params);
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? '0.0.0.0';
$vultrInstanceId = (string) $vultrInstance['id'];
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP;
$server = Server::create([
'name' => $normalizedServerName,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'vultr_instance_id' => $vultrInstance['id'],
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$ipAddress = $assignedIpAddress;
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
$server = DB::transaction(function () use ($normalizedServerName, $ipAddress, $teamId, $privateKey, $token, $vultrInstanceId, $vultrInstance): Server {
$server = Server::create([
'name' => $normalizedServerName,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'vultr_instance_id' => $vultrInstanceId,
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
}
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return $server;
});
try {
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
]);
}
} catch (\Throwable $e) {
report($e);
}
if ($request->instant_validate) {
ValidateServer::dispatch($server);
@@ -349,27 +366,48 @@ class VultrController extends Controller
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'vultr_instance_id' => $vultrInstance['id'],
'ip' => $ipAddress,
'vultr_instance_id' => $vultrInstanceId,
'ip' => $server->ip,
]);
return response()->json([
'uuid' => $server->uuid,
'vultr_instance_id' => $vultrInstance['id'],
'ip' => $ipAddress,
'vultr_instance_id' => $vultrInstanceId,
'ip' => $server->ip,
])->setStatusCode(201);
} catch (RateLimitException $e) {
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
$response = response()->json(['message' => $e->getMessage()], 429);
if ($e->retryAfter !== null) {
$response->header('Retry-After', $e->retryAfter);
}
return $response;
} catch (\Throwable) {
} catch (\Throwable $e) {
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
logger()->error('Failed to create Vultr server', [
'error' => $e->getMessage(),
]);
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
}
}
private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void
{
if (! $vultrService || ! $vultrInstanceId || $server) {
return;
}
try {
$vultrService->deleteInstance($vultrInstanceId);
} catch (\Throwable $e) {
report($e);
}
}
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
{
$normalizedPublicKey = $this->normalizePublicKey($publicKey);