Merge branch 'next' into update-n8n
This commit is contained in:
commit
2dd84317b4
209 changed files with 14653 additions and 9509 deletions
|
|
@ -142,6 +142,29 @@ Schema::create('applications', function (Blueprint $table) {
|
|||
- **Soft deletes** for audit trails
|
||||
- **Activity logging** with Spatie package
|
||||
|
||||
### **CRITICAL: Mass Assignment Protection**
|
||||
**When adding new database columns, you MUST update the model's `$fillable` array.** Without this, Laravel will silently ignore mass assignment operations like `Model::create()` or `$model->update()`.
|
||||
|
||||
**Checklist for new columns:**
|
||||
1. ✅ Create migration file
|
||||
2. ✅ Run migration
|
||||
3. ✅ **Add column to model's `$fillable` array**
|
||||
4. ✅ Update any Livewire components that sync this property
|
||||
5. ✅ Test that the column can be read and written
|
||||
|
||||
**Example:**
|
||||
```php
|
||||
class Server extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'ip',
|
||||
'port',
|
||||
'is_validating', // ← MUST add new columns here
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Relationship Patterns
|
||||
```php
|
||||
// Typical relationship structure in Application model
|
||||
|
|
|
|||
37
.github/workflows/coolify-staging-build.yml
vendored
37
.github/workflows/coolify-staging-build.yml
vendored
|
|
@ -28,6 +28,13 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sanitize branch name for Docker tag
|
||||
id: sanitize
|
||||
run: |
|
||||
# Replace slashes and other invalid characters with dashes
|
||||
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
|
||||
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
|
@ -50,8 +57,8 @@ jobs:
|
|||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
||||
|
||||
aarch64:
|
||||
runs-on: [self-hosted, arm64]
|
||||
|
|
@ -61,6 +68,13 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sanitize branch name for Docker tag
|
||||
id: sanitize
|
||||
run: |
|
||||
# Replace slashes and other invalid characters with dashes
|
||||
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
|
||||
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
|
@ -83,8 +97,8 @@ jobs:
|
|||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -95,6 +109,13 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sanitize branch name for Docker tag
|
||||
id: sanitize
|
||||
run: |
|
||||
# Replace slashes and other invalid characters with dashes
|
||||
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
|
||||
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
|
|
@ -114,14 +135,14 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
||||
|
||||
- uses: sarisia/actions-status-discord@v1
|
||||
if: always()
|
||||
|
|
|
|||
12958
CHANGELOG.md
12958
CHANGELOG.md
File diff suppressed because it is too large
Load diff
|
|
@ -160,6 +160,7 @@ ### Database Patterns
|
|||
- Use database transactions for critical operations
|
||||
- Leverage query scopes for reusable queries
|
||||
- Apply indexes for performance-critical queries
|
||||
- **CRITICAL**: When adding new database columns, ALWAYS update the model's `$fillable` array to allow mass assignment
|
||||
|
||||
### Security Best Practices
|
||||
- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -53,40 +53,40 @@ # Donations
|
|||
|
||||
## Big Sponsors
|
||||
|
||||
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
|
||||
* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management
|
||||
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
|
||||
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
|
||||
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
|
||||
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
|
||||
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
|
||||
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
|
||||
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
|
||||
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase
|
||||
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
|
||||
* [Trieve](https://trieve.ai?ref=coolify.io) - AI-powered search and analytics
|
||||
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data
|
||||
* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions
|
||||
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
|
||||
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
|
||||
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
|
||||
* [WZ-IT](https://wz-it.com/?ref=coolify.io) - German agency for customised cloud solutions
|
||||
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
|
||||
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
|
||||
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
|
||||
* [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network
|
||||
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
|
||||
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
|
||||
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
|
||||
* [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions
|
||||
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
|
||||
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services
|
||||
* [MassiveGrid](https://massivegrid.com?ref=coolify.io) - Enterprise cloud hosting solutions
|
||||
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers
|
||||
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
|
||||
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
|
||||
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
|
||||
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
|
||||
* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
|
||||
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
|
||||
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
|
||||
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
|
||||
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
|
||||
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
|
||||
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
|
||||
* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions
|
||||
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
|
||||
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
|
||||
* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
|
||||
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
|
||||
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
|
||||
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
|
||||
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
|
||||
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
|
||||
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
|
||||
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
||||
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
|
||||
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
|
||||
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
|
||||
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
|
||||
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase
|
||||
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data
|
||||
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers
|
||||
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
|
||||
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
|
||||
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
|
||||
|
||||
|
||||
## Small Sponsors
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ public function handle(StandaloneClickhouse $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ public function handle(StandaloneDragonfly $database)
|
|||
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||
|
||||
$server = $this->database->destination->server;
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -192,6 +192,8 @@ public function handle(StandaloneDragonfly $database)
|
|||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -56,11 +56,11 @@ public function handle(StandaloneKeydb $database)
|
|||
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||
|
||||
$server = $this->database->destination->server;
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -208,6 +208,8 @@ public function handle(StandaloneKeydb $database)
|
|||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@ public function handle(StandaloneMariadb $database)
|
|||
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||
|
||||
$server = $this->database->destination->server;
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -209,6 +209,8 @@ public function handle(StandaloneMariadb $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
if ($this->database->enable_ssl) {
|
||||
|
|
|
|||
|
|
@ -61,11 +61,11 @@ public function handle(StandaloneMongodb $database)
|
|||
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||
|
||||
$server = $this->database->destination->server;
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -260,6 +260,8 @@ public function handle(StandaloneMongodb $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = executeInDocker($this->database->uuid, 'chown mongodb:mongodb /etc/mongo/certs/server.pem');
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@ public function handle(StandaloneMysql $database)
|
|||
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||
|
||||
$server = $this->database->destination->server;
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -210,6 +210,8 @@ public function handle(StandaloneMysql $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
|
|
|
|||
|
|
@ -62,11 +62,11 @@ public function handle(StandalonePostgresql $database)
|
|||
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||
|
||||
$server = $this->database->destination->server;
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -223,6 +223,8 @@ public function handle(StandalonePostgresql $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
|
||||
|
|
|
|||
|
|
@ -56,11 +56,11 @@ public function handle(StandaloneRedis $database)
|
|||
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||
|
||||
$server = $this->database->destination->server;
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -205,6 +205,8 @@ public function handle(StandaloneRedis $database)
|
|||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
|
|||
{
|
||||
$server = $database->destination->server;
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=$timeout $containerName",
|
||||
"docker stop --timeout=$timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ public function handle(Server $server, bool $async = true, bool $force = false):
|
|||
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
|
||||
return 'OK';
|
||||
}
|
||||
$server->proxy->set('status', 'starting');
|
||||
$server->save();
|
||||
$server->refresh();
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
|
||||
$commands = collect([]);
|
||||
$proxy_path = $server->proxyPath();
|
||||
$configuration = GetProxyConfiguration::run($server);
|
||||
|
|
@ -64,14 +69,12 @@ public function handle(Server $server, bool $async = true, bool $force = false):
|
|||
]);
|
||||
$commands = $commands->merge(connectProxyToNetworks($server));
|
||||
}
|
||||
$server->proxy->set('status', 'starting');
|
||||
$server->save();
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
|
||||
if ($async) {
|
||||
return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id);
|
||||
} else {
|
||||
instant_remote_process($commands, $server);
|
||||
|
||||
$server->proxy->set('type', $proxyType);
|
||||
$server->save();
|
||||
ProxyStatusChanged::dispatch($server->id);
|
||||
|
|
|
|||
|
|
@ -2,16 +2,102 @@
|
|||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Server\HetznerDeletionFailed;
|
||||
use App\Services\HetznerService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeleteServer
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server)
|
||||
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
|
||||
{
|
||||
StopSentinel::run($server);
|
||||
$server->forceDelete();
|
||||
$server = Server::withTrashed()->find($serverId);
|
||||
|
||||
// Delete from Hetzner even if server is already gone from Coolify
|
||||
if ($deleteFromHetzner && ($hetznerServerId || ($server && $server->hetzner_server_id))) {
|
||||
$this->deleteFromHetznerById(
|
||||
$hetznerServerId ?? $server->hetzner_server_id,
|
||||
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
|
||||
$teamId ?? $server->team_id
|
||||
);
|
||||
}
|
||||
|
||||
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
|
||||
|
||||
// If server is already deleted from Coolify, skip this part
|
||||
if (! $server) {
|
||||
return; // Server already force deleted from Coolify
|
||||
}
|
||||
|
||||
ray('force deleting server from Coolify', ['server_id' => $server->id]);
|
||||
|
||||
try {
|
||||
$server->forceDelete();
|
||||
} catch (\Throwable $e) {
|
||||
ray('Failed to force delete server from Coolify', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
logger()->error('Failed to force delete server from Coolify', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void
|
||||
{
|
||||
try {
|
||||
// Use the provided token, or fallback to first available team token
|
||||
$token = null;
|
||||
|
||||
if ($cloudProviderTokenId) {
|
||||
$token = CloudProviderToken::find($cloudProviderTokenId);
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
$token = CloudProviderToken::where('team_id', $teamId)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
ray('No Hetzner token found for team, skipping Hetzner deletion', [
|
||||
'team_id' => $teamId,
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$hetznerService->deleteServer($hetznerServerId);
|
||||
|
||||
ray('Deleted server from Hetzner', [
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
ray('Failed to delete server from Hetzner', [
|
||||
'error' => $e->getMessage(),
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
// Log the error but don't prevent the server from being deleted from Coolify
|
||||
logger()->error('Failed to delete server from Hetzner', [
|
||||
'error' => $e->getMessage(),
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
// Notify the team about the failure
|
||||
$team = Team::find($teamId);
|
||||
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
|
|
@ -20,7 +19,7 @@ public function handle(Server $server)
|
|||
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
|
||||
}
|
||||
|
||||
if (! SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->exists()) {
|
||||
if (! $server->sslCertificates()->where('is_ca_certificate', true)->exists()) {
|
||||
$serverCert = SslHelper::generateSslCertificate(
|
||||
commonName: 'Coolify CA Certificate',
|
||||
serverId: $server->id,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
|
|
@ -58,6 +59,15 @@ private function cleanup_stucked_resources()
|
|||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stucked resources: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$servers = Server::onlyTrashed()->get();
|
||||
foreach ($servers as $server) {
|
||||
echo "Force deleting stuck server: {$server->name}\n";
|
||||
$server->forceDelete();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck servers: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$applicationsDeploymentQueue = ApplicationDeploymentQueue::get();
|
||||
foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) {
|
||||
|
|
@ -427,5 +437,18 @@ private function cleanup_stucked_resources()
|
|||
} catch (\Throwable $e) {
|
||||
echo "Error in ServiceDatabases: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) {
|
||||
$query->select('id')->from('servers');
|
||||
})->get();
|
||||
|
||||
foreach ($orphanedCerts as $cert) {
|
||||
echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n";
|
||||
$cert->delete();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
app/Console/Commands/ClearGlobalSearchCache.php
Normal file
83
app/Console/Commands/ClearGlobalSearchCache.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Livewire\GlobalSearch;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ClearGlobalSearchCache extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'search:clear {--team= : Clear cache for specific team ID} {--all : Clear cache for all teams}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Clear the global search cache for testing or manual refresh';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('all')) {
|
||||
return $this->clearAllTeamsCache();
|
||||
}
|
||||
|
||||
if ($teamId = $this->option('team')) {
|
||||
return $this->clearTeamCache($teamId);
|
||||
}
|
||||
|
||||
// If no options provided, clear cache for current user's team
|
||||
if (! auth()->check()) {
|
||||
$this->error('No authenticated user found. Use --team=ID or --all option.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return $this->clearTeamCache($teamId);
|
||||
}
|
||||
|
||||
private function clearTeamCache(int $teamId): int
|
||||
{
|
||||
$team = Team::find($teamId);
|
||||
|
||||
if (! $team) {
|
||||
$this->error("Team with ID {$teamId} not found.");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
$this->info("✓ Cleared global search cache for team: {$team->name} (ID: {$teamId})");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function clearAllTeamsCache(): int
|
||||
{
|
||||
$teams = Team::all();
|
||||
|
||||
if ($teams->isEmpty()) {
|
||||
$this->warn('No teams found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($teams as $team) {
|
||||
GlobalSearch::clearTeamCache($team->id);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("✓ Cleared global search cache for {$count} team(s)");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
51
app/Events/ServerValidated.php
Normal file
51
app/Events/ServerValidated.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ServerValidated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public ?int $teamId = null;
|
||||
|
||||
public ?string $serverUuid = null;
|
||||
|
||||
public function __construct(?int $teamId = null, ?string $serverUuid = null)
|
||||
{
|
||||
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
}
|
||||
$this->teamId = $teamId;
|
||||
$this->serverUuid = $serverUuid;
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
if (is_null($this->teamId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'ServerValidated';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'teamId' => $this->teamId,
|
||||
'serverUuid' => $this->serverUuid,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Services\DockerImageParser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
|
@ -1512,31 +1513,32 @@ private function create_application(Request $request, $type)
|
|||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
// Process docker image name and tag for SHA256 digests
|
||||
// Process docker image name and tag using DockerImageParser
|
||||
$dockerImageName = $request->docker_registry_image_name;
|
||||
$dockerImageTag = $request->docker_registry_image_tag;
|
||||
|
||||
// Strip 'sha256:' prefix if user provided it in the tag
|
||||
// Build the full Docker image string for parsing
|
||||
if ($dockerImageTag) {
|
||||
$dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag));
|
||||
$dockerImageString = $dockerImageName.':'.$dockerImageTag;
|
||||
} else {
|
||||
$dockerImageString = $dockerImageName;
|
||||
}
|
||||
|
||||
// Remove @sha256 from image name if user added it
|
||||
if ($dockerImageName) {
|
||||
$dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName));
|
||||
}
|
||||
// Parse using DockerImageParser to normalize the image reference
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($dockerImageString);
|
||||
|
||||
// Check if tag is a valid SHA256 hash (64 hex characters)
|
||||
$isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag);
|
||||
// Get normalized image name and tag
|
||||
$normalizedImageName = $parser->getFullImageNameWithoutTag();
|
||||
|
||||
// Append @sha256 to image name if using digest and not already present
|
||||
if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) {
|
||||
$dockerImageName .= '@sha256';
|
||||
// Append @sha256 to image name if using digest
|
||||
if ($parser->isImageHash() && ! str_ends_with($normalizedImageName, '@sha256')) {
|
||||
$normalizedImageName .= '@sha256';
|
||||
}
|
||||
|
||||
// Set processed values back to request
|
||||
$request->offsetSet('docker_registry_image_name', $dockerImageName);
|
||||
$request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest');
|
||||
$request->offsetSet('docker_registry_image_name', $normalizedImageName);
|
||||
$request->offsetSet('docker_registry_image_tag', $parser->getTag());
|
||||
|
||||
$application = new Application;
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
|
|
@ -2492,7 +2494,7 @@ public function envs(Request $request)
|
|||
)]
|
||||
public function update_env_by_uuid(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
|
|
@ -2520,6 +2522,8 @@ public function update_env_by_uuid(Request $request)
|
|||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
'is_runtime' => 'boolean',
|
||||
'is_buildtime' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
|
|
@ -2715,7 +2719,7 @@ public function create_bulk_envs(Request $request)
|
|||
], 400);
|
||||
}
|
||||
$bulk_data = collect($bulk_data)->map(function ($item) {
|
||||
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']);
|
||||
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']);
|
||||
});
|
||||
$returnedEnvs = collect();
|
||||
foreach ($bulk_data as $item) {
|
||||
|
|
@ -2726,6 +2730,8 @@ public function create_bulk_envs(Request $request)
|
|||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
'is_runtime' => 'boolean',
|
||||
'is_buildtime' => 'boolean',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
|
|
@ -2885,7 +2891,7 @@ public function create_bulk_envs(Request $request)
|
|||
)]
|
||||
public function create_env(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
|
|
@ -2908,6 +2914,8 @@ public function create_env(Request $request)
|
|||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
'is_runtime' => 'boolean',
|
||||
'is_buildtime' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
|
|
|
|||
|
|
@ -317,6 +317,10 @@ public function database_by_uuid(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_by_uuid(Request $request)
|
||||
|
|
@ -666,6 +670,10 @@ public function update_by_uuid(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_backup(Request $request)
|
||||
|
|
@ -844,6 +852,10 @@ public function update_backup(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_database_postgresql(Request $request)
|
||||
|
|
@ -907,6 +919,10 @@ public function create_database_postgresql(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_database_clickhouse(Request $request)
|
||||
|
|
@ -969,6 +985,10 @@ public function create_database_clickhouse(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_database_dragonfly(Request $request)
|
||||
|
|
@ -1032,6 +1052,10 @@ public function create_database_dragonfly(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_database_redis(Request $request)
|
||||
|
|
@ -1095,6 +1119,10 @@ public function create_database_redis(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_database_keydb(Request $request)
|
||||
|
|
@ -1161,6 +1189,10 @@ public function create_database_keydb(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_database_mariadb(Request $request)
|
||||
|
|
@ -1227,6 +1259,10 @@ public function create_database_mariadb(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_database_mysql(Request $request)
|
||||
|
|
@ -1290,6 +1326,10 @@ public function create_database_mysql(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_database_mongodb(Request $request)
|
||||
|
|
@ -1941,7 +1981,7 @@ public function delete_by_uuid(Request $request)
|
|||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'),
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Backup configuration and all executions deleted.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
|
|
@ -1951,7 +1991,7 @@ public function delete_by_uuid(Request $request)
|
|||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'),
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Backup configuration not found.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
|
|
@ -2065,7 +2105,7 @@ public function delete_backup_by_uuid(Request $request)
|
|||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'),
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Backup execution deleted.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
|
|
@ -2075,7 +2115,7 @@ public function delete_backup_by_uuid(Request $request)
|
|||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'),
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Backup execution not found.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
|
|
@ -2171,17 +2211,18 @@ public function delete_execution_by_uuid(Request $request)
|
|||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'executions' => new OA\Schema(
|
||||
new OA\Property(
|
||||
property: 'executions',
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'filename' => ['type' => 'string'],
|
||||
'size' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'message' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string'],
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'filename', type: 'string'),
|
||||
new OA\Property(property: 'size', type: 'integer'),
|
||||
new OA\Property(property: 'created_at', type: 'string'),
|
||||
new OA\Property(property: 'message', type: 'string'),
|
||||
new OA\Property(property: 'status', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -219,7 +219,8 @@ public function create_github_app(Request $request)
|
|||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'repositories' => new OA\Schema(
|
||||
new OA\Property(
|
||||
property: 'repositories',
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'object')
|
||||
),
|
||||
|
|
@ -335,7 +336,8 @@ public function load_repositories($github_app_id)
|
|||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'branches' => new OA\Schema(
|
||||
new OA\Property(
|
||||
property: 'branches',
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'object')
|
||||
),
|
||||
|
|
@ -457,7 +459,7 @@ public function load_branches($github_app_id, $owner, $repo)
|
|||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitHub app not found'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_github_app(Request $request, $github_app_id)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,27 @@
|
|||
new OA\Property(property: 'message', type: 'string', example: 'Resource not found.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
description: 'Validation error.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Validation error.'),
|
||||
new OA\Property(
|
||||
property: 'errors',
|
||||
type: 'object',
|
||||
additionalProperties: new OA\AdditionalProperties(
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'string')
|
||||
),
|
||||
example: [
|
||||
'name' => ['The name field is required.'],
|
||||
'api_url' => ['The api url field is required.', 'The api url format is invalid.'],
|
||||
]
|
||||
),
|
||||
]
|
||||
)),
|
||||
],
|
||||
)]
|
||||
class OpenApi
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ class OtherController extends Controller
|
|||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Returns the version of the application',
|
||||
content: new OA\JsonContent(
|
||||
type: 'string',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'text/html',
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
example: 'v4.0.0',
|
||||
)),
|
||||
new OA\Response(
|
||||
|
|
@ -166,8 +167,9 @@ public function feedback(Request $request)
|
|||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Healthcheck endpoint.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'string',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'text/html',
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
example: 'OK',
|
||||
)),
|
||||
new OA\Response(
|
||||
|
|
|
|||
|
|
@ -134,6 +134,10 @@ public function project_by_uuid(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function environment_details(Request $request)
|
||||
|
|
@ -214,6 +218,10 @@ public function environment_details(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_project(Request $request)
|
||||
|
|
@ -324,6 +332,10 @@ public function create_project(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_project(Request $request)
|
||||
|
|
@ -425,6 +437,10 @@ public function update_project(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_project(Request $request)
|
||||
|
|
@ -487,6 +503,10 @@ public function delete_project(Request $request)
|
|||
response: 404,
|
||||
description: 'Project not found.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function get_environments(Request $request)
|
||||
|
|
@ -566,6 +586,10 @@ public function get_environments(Request $request)
|
|||
response: 409,
|
||||
description: 'Environment with this name already exists.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_environment(Request $request)
|
||||
|
|
@ -663,6 +687,10 @@ public function create_environment(Request $request)
|
|||
response: 404,
|
||||
description: 'Project or environment not found.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_environment(Request $request)
|
||||
|
|
|
|||
|
|
@ -163,6 +163,10 @@ public function key_by_uuid(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_key(Request $request)
|
||||
|
|
@ -282,6 +286,10 @@ public function create_key(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_key(Request $request)
|
||||
|
|
|
|||
|
|
@ -447,6 +447,10 @@ public function domains_by_server(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_server(Request $request)
|
||||
|
|
@ -604,6 +608,10 @@ public function create_server(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_server(Request $request)
|
||||
|
|
@ -722,6 +730,10 @@ public function update_server(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_server(Request $request)
|
||||
|
|
@ -746,7 +758,13 @@ public function delete_server(Request $request)
|
|||
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
|
||||
}
|
||||
$server->delete();
|
||||
DeleteServer::dispatch($server);
|
||||
DeleteServer::dispatch(
|
||||
$server->id,
|
||||
false, // Don't delete from Hetzner via API
|
||||
$server->hetzner_server_id,
|
||||
$server->cloud_provider_token_id,
|
||||
$server->team_id
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Server deleted.']);
|
||||
}
|
||||
|
|
@ -790,6 +808,10 @@ public function delete_server(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function validate_server(Request $request)
|
||||
|
|
|
|||
|
|
@ -235,6 +235,10 @@ public function services(Request $request)
|
|||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_service(Request $request)
|
||||
|
|
@ -704,6 +708,10 @@ public function delete_by_uuid(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_by_uuid(Request $request)
|
||||
|
|
@ -954,6 +962,10 @@ public function envs(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_env_by_uuid(Request $request)
|
||||
|
|
@ -1075,6 +1087,10 @@ public function update_env_by_uuid(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_bulk_envs(Request $request)
|
||||
|
|
@ -1191,6 +1207,10 @@ public function create_bulk_envs(Request $request)
|
|||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_env(Request $request)
|
||||
|
|
|
|||
|
|
@ -484,9 +484,18 @@ private function deploy_simple_dockerfile()
|
|||
);
|
||||
$this->generate_image_names();
|
||||
$this->generate_compose_file();
|
||||
|
||||
// Save build-time .env file BEFORE the build
|
||||
$this->save_buildtime_environment_variables();
|
||||
|
||||
$this->generate_build_env_variables();
|
||||
$this->add_build_env_variables_to_dockerfile();
|
||||
$this->build_image();
|
||||
|
||||
// Save runtime environment variables AFTER the build
|
||||
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
||||
$this->save_runtime_environment_variables();
|
||||
|
||||
$this->push_to_docker_registry();
|
||||
$this->rolling_update();
|
||||
}
|
||||
|
|
@ -1310,12 +1319,18 @@ private function save_runtime_environment_variables()
|
|||
|
||||
private function generate_buildtime_environment_variables()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] Generating build-time environment variables');
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||
}
|
||||
|
||||
$envs = collect([]);
|
||||
$coolify_envs = $this->generate_coolify_env_variables();
|
||||
|
||||
// Add COOLIFY variables
|
||||
$coolify_envs->each(function ($item, $key) use ($envs) {
|
||||
$envs->push($key.'='.$item);
|
||||
$envs->push($key.'='.escapeBashEnvValue($item));
|
||||
});
|
||||
|
||||
// Add SERVICE_NAME variables for Docker Compose builds
|
||||
|
|
@ -1329,7 +1344,7 @@ private function generate_buildtime_environment_variables()
|
|||
}
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
foreach ($services as $serviceName => $_) {
|
||||
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
|
||||
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName));
|
||||
}
|
||||
|
||||
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
|
||||
|
|
@ -1342,8 +1357,8 @@ private function generate_buildtime_environment_variables()
|
|||
$coolifyScheme = $coolifyUrl->getScheme();
|
||||
$coolifyFqdn = $coolifyUrl->getHost();
|
||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
|
||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
|
||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
|
||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1351,7 +1366,7 @@ private function generate_buildtime_environment_variables()
|
|||
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||
$rawServices = data_get($rawDockerCompose, 'services', []);
|
||||
foreach ($rawServices as $rawServiceName => $_) {
|
||||
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
|
||||
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)));
|
||||
}
|
||||
|
||||
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
|
||||
|
|
@ -1364,8 +1379,8 @@ private function generate_buildtime_environment_variables()
|
|||
$coolifyScheme = $coolifyUrl->getScheme();
|
||||
$coolifyFqdn = $coolifyUrl->getHost();
|
||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
|
||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
|
||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
|
||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1387,7 +1402,32 @@ private function generate_buildtime_environment_variables()
|
|||
}
|
||||
|
||||
foreach ($sorted_environment_variables as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
// For literal/multiline vars, real_value includes quotes that we need to remove
|
||||
if ($env->is_literal || $env->is_multiline) {
|
||||
// Strip outer quotes from real_value and apply proper bash escaping
|
||||
$value = trim($env->real_value, "'");
|
||||
$escapedValue = escapeBashEnvValue($value);
|
||||
$envs->push($env->key.'='.$escapedValue);
|
||||
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
|
||||
}
|
||||
} else {
|
||||
// For normal vars, use double quotes to allow $VAR expansion
|
||||
$escapedValue = escapeBashDoubleQuoted($env->real_value);
|
||||
$envs->push($env->key.'='.$escapedValue);
|
||||
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$sorted_environment_variables = $this->application->environment_variables_preview()
|
||||
|
|
@ -1404,11 +1444,42 @@ private function generate_buildtime_environment_variables()
|
|||
}
|
||||
|
||||
foreach ($sorted_environment_variables as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
// For literal/multiline vars, real_value includes quotes that we need to remove
|
||||
if ($env->is_literal || $env->is_multiline) {
|
||||
// Strip outer quotes from real_value and apply proper bash escaping
|
||||
$value = trim($env->real_value, "'");
|
||||
$escapedValue = escapeBashEnvValue($value);
|
||||
$envs->push($env->key.'='.$escapedValue);
|
||||
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
|
||||
}
|
||||
} else {
|
||||
// For normal vars, use double quotes to allow $VAR expansion
|
||||
$escapedValue = escapeBashDoubleQuoted($env->real_value);
|
||||
$envs->push($env->key.'='.$escapedValue);
|
||||
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the generated environment variables
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Total build-time env variables: {$envs->count()}");
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||
}
|
||||
|
||||
return $envs;
|
||||
}
|
||||
|
||||
|
|
@ -1432,9 +1503,9 @@ private function save_buildtime_environment_variables()
|
|||
'hidden' => true,
|
||||
],
|
||||
);
|
||||
} elseif ($this->build_pack === 'dockercompose') {
|
||||
// For Docker Compose, create an empty .env file even if there are no build-time variables
|
||||
// This ensures the file exists when referenced in docker-compose commands
|
||||
} elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') {
|
||||
// For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables
|
||||
// This ensures the file exists when referenced in build commands
|
||||
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
|
||||
|
||||
$this->execute_remote_command(
|
||||
|
|
@ -1888,9 +1959,27 @@ private function check_git_if_build_needed()
|
|||
);
|
||||
}
|
||||
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) {
|
||||
$this->commit = $this->saved_outputs->get('git_commit_sha')->before("\t");
|
||||
$this->application_deployment_queue->commit = $this->commit;
|
||||
$this->application_deployment_queue->save();
|
||||
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
|
||||
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
|
||||
// Note: Git warnings can be on the same line as the result (no newline)
|
||||
$lsRemoteOutput = $this->saved_outputs->get('git_commit_sha');
|
||||
|
||||
// Find the part containing a tab (the actual ls-remote result)
|
||||
// Handle cases where warning is on the same line as the result
|
||||
if ($lsRemoteOutput->contains("\t")) {
|
||||
// Get everything from the last occurrence of a valid commit SHA pattern before the tab
|
||||
// A valid commit SHA is 40 hex characters
|
||||
$output = $lsRemoteOutput->value();
|
||||
|
||||
// Extract the line with the tab (actual ls-remote result)
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
|
||||
if (isset($matches[1])) {
|
||||
$this->commit = $matches[1];
|
||||
$this->application_deployment_queue->commit = $this->commit;
|
||||
$this->application_deployment_queue->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->set_coolify_variables();
|
||||
|
||||
|
|
@ -1905,7 +1994,7 @@ private function clone_repository()
|
|||
{
|
||||
$importCommands = $this->generate_git_import_commands();
|
||||
$this->application_deployment_queue->addLogEntry("\n----------------------------------------");
|
||||
$this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->application->git_commit_sha}) to {$this->basedir}.");
|
||||
$this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->commit}) to {$this->basedir}.");
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
|
||||
}
|
||||
|
|
@ -2701,10 +2790,12 @@ private function build_image()
|
|||
]
|
||||
);
|
||||
}
|
||||
$publishDir = trim($this->application->publish_directory, '/');
|
||||
$publishDir = $publishDir ? "/{$publishDir}" : '';
|
||||
$dockerfile = base64_encode("FROM {$this->application->static_image}
|
||||
WORKDIR /usr/share/nginx/html/
|
||||
LABEL coolify.deploymentId={$this->deployment_uuid}
|
||||
COPY --from=$this->build_image_name /app/{$this->application->publish_directory} .
|
||||
COPY --from=$this->build_image_name /app{$publishDir} .
|
||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
||||
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
||||
|
|
@ -3196,7 +3287,7 @@ private function add_build_env_variables_to_dockerfile()
|
|||
}
|
||||
|
||||
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
|
||||
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info');
|
||||
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
|
||||
|
|
|
|||
|
|
@ -39,16 +39,17 @@ public function handle()
|
|||
$this->delete_comment();
|
||||
|
||||
return;
|
||||
} elseif ($this->status === ProcessStatus::IN_PROGRESS) {
|
||||
$this->body = "The preview deployment is in progress. 🟡\n\n";
|
||||
} elseif ($this->status === ProcessStatus::FINISHED) {
|
||||
$this->body = "The preview deployment is ready. 🟢\n\n";
|
||||
if ($this->preview->fqdn) {
|
||||
$this->body .= "[Open Preview]({$this->preview->fqdn}) | ";
|
||||
}
|
||||
} elseif ($this->status === ProcessStatus::ERROR) {
|
||||
$this->body = "The preview deployment failed. 🔴\n\n";
|
||||
}
|
||||
|
||||
match ($this->status) {
|
||||
ProcessStatus::QUEUED => $this->body = "The preview deployment is queued. ⏳\n\n",
|
||||
ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment is in progress. 🟡\n\n",
|
||||
ProcessStatus::FINISHED => $this->body = "The preview deployment is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''),
|
||||
ProcessStatus::ERROR => $this->body = "The preview deployment failed. 🔴\n\n",
|
||||
ProcessStatus::KILLED => $this->body = "The preview deployment was killed. ⚫\n\n",
|
||||
ProcessStatus::CANCELLED => $this->body = "The preview deployment was cancelled. 🚫\n\n",
|
||||
ProcessStatus::CLOSED => '', // Already handled above, but included for completeness
|
||||
};
|
||||
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
|
||||
|
||||
$this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n";
|
||||
|
|
|
|||
|
|
@ -69,13 +69,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public $timeout = 3600;
|
||||
|
||||
public string $backup_log_uuid;
|
||||
public ?string $backup_log_uuid = null;
|
||||
|
||||
public function __construct(public ScheduledDatabaseBackup $backup)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$this->timeout = $backup->timeout;
|
||||
$this->backup_log_uuid = (string) new Cuid2;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ public function handle()
|
|||
|
||||
$query->cursor()->each(function ($certificate) use ($regenerated) {
|
||||
try {
|
||||
$caCert = SslCertificate::where('server_id', $certificate->server_id)
|
||||
$caCert = $certificate->server->sslCertificates()
|
||||
->where('is_ca_certificate', true)
|
||||
->first();
|
||||
|
||||
|
|
|
|||
60
app/Jobs/SendWebhookJob.php
Normal file
60
app/Jobs/SendWebhookJob.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
public $backoff = 10;
|
||||
|
||||
/**
|
||||
* The maximum number of unhandled exceptions to allow before failing.
|
||||
*/
|
||||
public int $maxExceptions = 5;
|
||||
|
||||
public function __construct(
|
||||
public array $payload,
|
||||
public string $webhookUrl
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
if (isDev()) {
|
||||
ray('Sending webhook notification', [
|
||||
'url' => $this->webhookUrl,
|
||||
'payload' => $this->payload,
|
||||
]);
|
||||
}
|
||||
|
||||
$response = Http::post($this->webhookUrl, $this->payload);
|
||||
|
||||
if (isDev()) {
|
||||
ray('Webhook response', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
'successful' => $response->successful(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,11 @@ public function handle()
|
|||
return;
|
||||
}
|
||||
|
||||
// Check Hetzner server status if applicable
|
||||
if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) {
|
||||
$this->checkHetznerStatus();
|
||||
}
|
||||
|
||||
// Temporarily disable mux if requested
|
||||
if ($this->disableMux) {
|
||||
$this->disableSshMux();
|
||||
|
|
@ -86,6 +91,11 @@ public function handle()
|
|||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
Log::error('ServerConnectionCheckJob failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
|
|
@ -95,6 +105,30 @@ public function handle()
|
|||
}
|
||||
}
|
||||
|
||||
private function checkHetznerStatus(): void
|
||||
{
|
||||
try {
|
||||
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
|
||||
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
|
||||
$status = $serverData['status'] ?? null;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('ServerConnectionCheck: Hetzner status check failed', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
if ($this->server->hetzner_server_status !== $status) {
|
||||
$this->server->update(['hetzner_server_status' => $status]);
|
||||
$this->server->hetzner_server_status = $status;
|
||||
if ($status === 'off') {
|
||||
ray('Server is powered off, marking as unreachable');
|
||||
throw new \Exception('Server is powered off');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function checkConnection(): bool
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
162
app/Jobs/ValidateAndInstallServerJob.php
Normal file
162
app/Jobs/ValidateAndInstallServerJob.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Events\ServerValidated;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ValidateAndInstallServerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 600; // 10 minutes
|
||||
|
||||
public int $maxTries = 3;
|
||||
|
||||
public function __construct(
|
||||
public Server $server,
|
||||
public int $numberOfTries = 0
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
// Mark validation as in progress
|
||||
$this->server->update(['is_validating' => true]);
|
||||
|
||||
Log::info('ValidateAndInstallServer: Starting validation', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
'attempt' => $this->numberOfTries + 1,
|
||||
]);
|
||||
|
||||
// Validate connection
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if (! $uptime) {
|
||||
$errorMessage = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error;
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Server not reachable', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $error,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate OS
|
||||
$supportedOsType = $this->server->validateOS();
|
||||
if (! $supportedOsType) {
|
||||
$errorMessage = 'Server OS type is not supported. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: OS not supported', [
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Docker is installed
|
||||
$dockerInstalled = $this->server->validateDockerEngine();
|
||||
$dockerComposeInstalled = $this->server->validateDockerCompose();
|
||||
|
||||
if (! $dockerInstalled || ! $dockerComposeInstalled) {
|
||||
// Try to install Docker
|
||||
if ($this->numberOfTries >= $this->maxTries) {
|
||||
$errorMessage = 'Docker Engine could not be installed after '.$this->maxTries.' attempts. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Docker installation failed after max tries', [
|
||||
'server_id' => $this->server->id,
|
||||
'attempts' => $this->numberOfTries,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('ValidateAndInstallServer: Installing Docker', [
|
||||
'server_id' => $this->server->id,
|
||||
'attempt' => $this->numberOfTries + 1,
|
||||
]);
|
||||
|
||||
// Install Docker
|
||||
$this->server->installDocker();
|
||||
|
||||
// Retry validation after installation
|
||||
self::dispatch($this->server, $this->numberOfTries + 1)->delay(now()->addSeconds(30));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate Docker version
|
||||
$dockerVersion = $this->server->validateDockerEngineVersion();
|
||||
if (! $dockerVersion) {
|
||||
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
|
||||
$errorMessage = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Docker version not sufficient', [
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation successful!
|
||||
Log::info('ValidateAndInstallServer: Validation successful', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
|
||||
// Start proxy if needed
|
||||
if (! $this->server->isBuildServer()) {
|
||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||
if ($proxyShouldRun) {
|
||||
StartProxy::dispatch($this->server);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark validation as complete
|
||||
$this->server->update(['is_validating' => false]);
|
||||
|
||||
// Refresh server to get latest state
|
||||
$this->server->refresh();
|
||||
|
||||
// Broadcast events to update UI
|
||||
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
|
||||
ServerReachabilityChanged::dispatch($this->server);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ValidateAndInstallServer: Exception occurred', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$this->server->update([
|
||||
'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
|
||||
'is_validating' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,14 +16,18 @@ class Index extends Component
|
|||
{
|
||||
protected $listeners = ['refreshBoardingIndex' => 'validateServer'];
|
||||
|
||||
#[\Livewire\Attributes\Url(as: 'step', history: true)]
|
||||
public string $currentState = 'welcome';
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
public ?string $selectedServerType = null;
|
||||
|
||||
public ?Collection $privateKeys = null;
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
public ?int $selectedExistingPrivateKey = null;
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
public ?string $privateKeyType = null;
|
||||
|
||||
public ?string $privateKey = null;
|
||||
|
|
@ -38,6 +42,7 @@ class Index extends Component
|
|||
|
||||
public ?Collection $servers = null;
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
public ?int $selectedExistingServer = null;
|
||||
|
||||
public ?string $remoteServerName = null;
|
||||
|
|
@ -58,6 +63,7 @@ class Index extends Component
|
|||
|
||||
public Collection $projects;
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
public ?int $selectedProject = null;
|
||||
|
||||
public ?Project $createdProject = null;
|
||||
|
|
@ -79,17 +85,68 @@ public function mount()
|
|||
$this->minDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
|
||||
$this->privateKeyName = generate_random_name();
|
||||
$this->remoteServerName = generate_random_name();
|
||||
if (isDev()) {
|
||||
$this->privateKey = '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----';
|
||||
$this->privateKeyDescription = 'Created by Coolify';
|
||||
$this->remoteServerDescription = 'Created by Coolify';
|
||||
$this->remoteServerHost = 'coolify-testing-host';
|
||||
|
||||
// Initialize collections to avoid null errors
|
||||
if ($this->privateKeys === null) {
|
||||
$this->privateKeys = collect();
|
||||
}
|
||||
if ($this->servers === null) {
|
||||
$this->servers = collect();
|
||||
}
|
||||
if (! isset($this->projects)) {
|
||||
$this->projects = collect();
|
||||
}
|
||||
|
||||
// Restore state when coming from URL with query params
|
||||
if ($this->selectedServerType === 'localhost' && $this->selectedExistingServer === 0) {
|
||||
$this->createdServer = Server::find(0);
|
||||
if ($this->createdServer) {
|
||||
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->selectedServerType === 'remote') {
|
||||
if ($this->privateKeys->isEmpty()) {
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
|
||||
}
|
||||
if ($this->servers->isEmpty()) {
|
||||
$this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
|
||||
}
|
||||
|
||||
if ($this->selectedExistingServer) {
|
||||
$this->createdServer = Server::find($this->selectedExistingServer);
|
||||
if ($this->createdServer) {
|
||||
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
|
||||
$this->updateServerDetails();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->selectedExistingPrivateKey) {
|
||||
$this->createdPrivateKey = PrivateKey::where('team_id', currentTeam()->id)
|
||||
->where('id', $this->selectedExistingPrivateKey)
|
||||
->first();
|
||||
if ($this->createdPrivateKey) {
|
||||
$this->privateKey = $this->createdPrivateKey->private_key;
|
||||
$this->publicKey = $this->createdPrivateKey->getPublicKey();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-regenerate key pair for "Generate with Coolify" mode on page refresh
|
||||
if ($this->privateKeyType === 'create' && empty($this->privateKey)) {
|
||||
$this->createNewPrivateKey();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->selectedProject) {
|
||||
$this->createdProject = Project::find($this->selectedProject);
|
||||
if (! $this->createdProject) {
|
||||
$this->projects = Project::ownedByCurrentTeam(['name'])->get();
|
||||
}
|
||||
}
|
||||
|
||||
// Load projects when on create-project state (for page refresh)
|
||||
if ($this->currentState === 'create-project' && $this->projects->isEmpty()) {
|
||||
$this->projects = Project::ownedByCurrentTeam(['name'])->get();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,41 +186,16 @@ public function setServerType(string $type)
|
|||
|
||||
return $this->validateServer('localhost');
|
||||
} elseif ($this->selectedServerType === 'remote') {
|
||||
if (isDev()) {
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->get();
|
||||
} else {
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
|
||||
}
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
|
||||
// Auto-select first key if available for better UX
|
||||
if ($this->privateKeys->count() > 0) {
|
||||
$this->selectedExistingPrivateKey = $this->privateKeys->first()->id;
|
||||
}
|
||||
$this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
|
||||
if ($this->servers->count() > 0) {
|
||||
$this->selectedExistingServer = $this->servers->first()->id;
|
||||
$this->updateServerDetails();
|
||||
$this->currentState = 'select-existing-server';
|
||||
|
||||
return;
|
||||
}
|
||||
// Onboarding always creates new servers, skip existing server selection
|
||||
$this->currentState = 'private-key';
|
||||
}
|
||||
}
|
||||
|
||||
public function selectExistingServer()
|
||||
{
|
||||
$this->createdServer = Server::find($this->selectedExistingServer);
|
||||
if (! $this->createdServer) {
|
||||
$this->dispatch('error', 'Server is not found.');
|
||||
$this->currentState = 'private-key';
|
||||
|
||||
return;
|
||||
}
|
||||
$this->selectedExistingPrivateKey = $this->createdServer->privateKey->id;
|
||||
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
|
||||
$this->updateServerDetails();
|
||||
$this->currentState = 'validate-server';
|
||||
}
|
||||
|
||||
private function updateServerDetails()
|
||||
{
|
||||
if ($this->createdServer) {
|
||||
|
|
@ -181,7 +213,7 @@ public function getProxyType()
|
|||
public function selectExistingPrivateKey()
|
||||
{
|
||||
if (is_null($this->selectedExistingPrivateKey)) {
|
||||
$this->restartBoarding();
|
||||
$this->dispatch('error', 'Please select a private key.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -202,6 +234,9 @@ public function setPrivateKey(string $type)
|
|||
$this->privateKeyType = $type;
|
||||
if ($type === 'create') {
|
||||
$this->createNewPrivateKey();
|
||||
} else {
|
||||
$this->privateKey = null;
|
||||
$this->publicKey = null;
|
||||
}
|
||||
$this->currentState = 'create-private-key';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -617,7 +617,14 @@ private function loadSearchableItems()
|
|||
'type' => 'navigation',
|
||||
'description' => 'Manage private keys and API tokens',
|
||||
'link' => route('security.private-key.index'),
|
||||
'search_text' => 'security private keys ssh api tokens',
|
||||
'search_text' => 'security private keys ssh api tokens cloud-init scripts',
|
||||
],
|
||||
[
|
||||
'name' => 'Cloud-Init Scripts',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage reusable cloud-init scripts',
|
||||
'link' => route('security.cloud-init-scripts'),
|
||||
'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup',
|
||||
],
|
||||
[
|
||||
'name' => 'Sources',
|
||||
|
|
@ -1140,6 +1147,9 @@ private function navigateToResourceCreation($type)
|
|||
$this->selectedResourceType = $type;
|
||||
$this->isSelectingResource = true;
|
||||
|
||||
// Clear search query to show selection UI instead of creatable items
|
||||
$this->searchQuery = '';
|
||||
|
||||
// Reset selections
|
||||
$this->selectedServerId = null;
|
||||
$this->selectedDestinationUuid = null;
|
||||
|
|
@ -1309,10 +1319,10 @@ private function completeResourceCreation()
|
|||
$queryParams['database_image'] = 'postgres:16-alpine';
|
||||
}
|
||||
|
||||
return redirect()->route('project.resource.create', [
|
||||
$this->redirect(route('project.resource.create', [
|
||||
'project_uuid' => $this->selectedProjectUuid,
|
||||
'environment_uuid' => $this->selectedEnvironmentUuid,
|
||||
] + $queryParams);
|
||||
] + $queryParams));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
196
app/Livewire/Notifications/Webhook.php
Normal file
196
app/Livewire/Notifications/Webhook.php
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Notifications;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
use App\Notifications\Test;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
class Webhook extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Team $team;
|
||||
|
||||
public WebhookNotificationSettings $settings;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $webhookEnabled = false;
|
||||
|
||||
#[Validate(['url', 'nullable'])]
|
||||
public ?string $webhookUrl = null;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $deploymentSuccessWebhookNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $deploymentFailureWebhookNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $statusChangeWebhookNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $backupSuccessWebhookNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $backupFailureWebhookNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $scheduledTaskSuccessWebhookNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $scheduledTaskFailureWebhookNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $dockerCleanupSuccessWebhookNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $dockerCleanupFailureWebhookNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverDiskUsageWebhookNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverReachableWebhookNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverUnreachableWebhookNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchWebhookNotifications = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->team = auth()->user()->currentTeam();
|
||||
$this->settings = $this->team->webhookNotificationSettings;
|
||||
$this->authorize('view', $this->settings);
|
||||
$this->syncData();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->webhook_enabled = $this->webhookEnabled;
|
||||
$this->settings->webhook_url = $this->webhookUrl;
|
||||
|
||||
$this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications;
|
||||
$this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications;
|
||||
$this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications;
|
||||
$this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications;
|
||||
$this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications;
|
||||
$this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications;
|
||||
$this->settings->scheduled_task_failure_webhook_notifications = $this->scheduledTaskFailureWebhookNotifications;
|
||||
$this->settings->docker_cleanup_success_webhook_notifications = $this->dockerCleanupSuccessWebhookNotifications;
|
||||
$this->settings->docker_cleanup_failure_webhook_notifications = $this->dockerCleanupFailureWebhookNotifications;
|
||||
$this->settings->server_disk_usage_webhook_notifications = $this->serverDiskUsageWebhookNotifications;
|
||||
$this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications;
|
||||
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
|
||||
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
|
||||
|
||||
$this->settings->save();
|
||||
refreshSession();
|
||||
} else {
|
||||
$this->webhookEnabled = $this->settings->webhook_enabled;
|
||||
$this->webhookUrl = $this->settings->webhook_url;
|
||||
|
||||
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
|
||||
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
|
||||
$this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications;
|
||||
$this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications;
|
||||
$this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications;
|
||||
$this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications;
|
||||
$this->scheduledTaskFailureWebhookNotifications = $this->settings->scheduled_task_failure_webhook_notifications;
|
||||
$this->dockerCleanupSuccessWebhookNotifications = $this->settings->docker_cleanup_success_webhook_notifications;
|
||||
$this->dockerCleanupFailureWebhookNotifications = $this->settings->docker_cleanup_failure_webhook_notifications;
|
||||
$this->serverDiskUsageWebhookNotifications = $this->settings->server_disk_usage_webhook_notifications;
|
||||
$this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications;
|
||||
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
|
||||
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSaveWebhookEnabled()
|
||||
{
|
||||
try {
|
||||
$original = $this->webhookEnabled;
|
||||
$this->validate([
|
||||
'webhookUrl' => 'required',
|
||||
], [
|
||||
'webhookUrl.required' => 'Webhook URL is required.',
|
||||
]);
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->webhookEnabled = $original;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->syncData(true);
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->syncData(true);
|
||||
refreshSession();
|
||||
|
||||
if (isDev()) {
|
||||
ray('Webhook settings saved', [
|
||||
'webhook_enabled' => $this->settings->webhook_enabled,
|
||||
'webhook_url' => $this->settings->webhook_url,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
}
|
||||
|
||||
public function sendTestNotification()
|
||||
{
|
||||
try {
|
||||
$this->authorize('sendTest', $this->settings);
|
||||
|
||||
if (isDev()) {
|
||||
ray('Sending test webhook notification', [
|
||||
'team_id' => $this->team->id,
|
||||
'webhook_url' => $this->settings->webhook_url,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->team->notify(new Test(channel: 'webhook'));
|
||||
$this->dispatch('success', 'Test notification sent.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.notifications.webhook');
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +85,7 @@ class BackupEdit extends Component
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->backup->database);
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->syncData();
|
||||
} catch (Exception $e) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneClickhouse $database;
|
||||
|
||||
|
|
@ -56,8 +56,14 @@ public function getListeners()
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use Auth;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Configuration extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
public $currentRoute;
|
||||
|
||||
public $database;
|
||||
|
|
@ -42,6 +44,8 @@ public function mount()
|
|||
->where('uuid', request()->route('database_uuid'))
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize('view', $database);
|
||||
|
||||
$this->database = $database;
|
||||
$this->project = $project;
|
||||
$this->environment = $environment;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -19,7 +18,7 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneDragonfly $database;
|
||||
|
||||
|
|
@ -63,8 +62,14 @@ public function getListeners()
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
|
|
@ -249,13 +254,13 @@ public function regenerateSslCertificate()
|
|||
|
||||
$server = $this->database->destination->server;
|
||||
|
||||
$caCert = SslCertificate::where('server_id', $server->id)
|
||||
$caCert = $server->sslCertificates()
|
||||
->where('is_ca_certificate', true)
|
||||
->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ public function getContainers()
|
|||
if (is_null($resource)) {
|
||||
abort(404);
|
||||
}
|
||||
$this->authorize('view', $resource);
|
||||
$this->resource = $resource;
|
||||
$this->server = $this->resource->destination->server;
|
||||
$this->container = $this->resource->uuid;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -19,7 +18,7 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneKeydb $database;
|
||||
|
||||
|
|
@ -59,15 +58,20 @@ public function getListeners()
|
|||
return [
|
||||
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'refresh' => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
|
|
@ -255,7 +259,7 @@ public function regenerateSslCertificate()
|
|||
return;
|
||||
}
|
||||
|
||||
$caCert = SslCertificate::where('server_id', $existingCert->server_id)
|
||||
$caCert = $this->server->sslCertificates()
|
||||
->where('is_ca_certificate', true)
|
||||
->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -19,12 +18,38 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected $listeners = ['refresh'];
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneMariadb $database;
|
||||
|
||||
public string $name;
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
public string $mariadbRootPassword;
|
||||
|
||||
public string $mariadbUser;
|
||||
|
||||
public string $mariadbPassword;
|
||||
|
||||
public string $mariadbDatabase;
|
||||
|
||||
public ?string $mariadbConf = null;
|
||||
|
||||
public string $image;
|
||||
|
||||
public ?string $portsMappings = null;
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public bool $enableSsl = false;
|
||||
|
||||
public ?string $db_url = null;
|
||||
|
||||
public ?string $db_url_public = null;
|
||||
|
|
@ -37,27 +62,26 @@ public function getListeners()
|
|||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'refresh' => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'database.name' => ValidationPatterns::nameRules(),
|
||||
'database.description' => ValidationPatterns::descriptionRules(),
|
||||
'database.mariadb_root_password' => 'required',
|
||||
'database.mariadb_user' => 'required',
|
||||
'database.mariadb_password' => 'required',
|
||||
'database.mariadb_database' => 'required',
|
||||
'database.mariadb_conf' => 'nullable',
|
||||
'database.image' => 'required',
|
||||
'database.ports_mappings' => 'nullable',
|
||||
'database.is_public' => 'nullable|boolean',
|
||||
'database.public_port' => 'nullable|integer',
|
||||
'database.is_log_drain_enabled' => 'nullable|boolean',
|
||||
'database.custom_docker_run_options' => 'nullable',
|
||||
'database.enable_ssl' => 'boolean',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'mariadbRootPassword' => 'required',
|
||||
'mariadbUser' => 'required',
|
||||
'mariadbPassword' => 'required',
|
||||
'mariadbDatabase' => 'required',
|
||||
'mariadbConf' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -66,45 +90,96 @@ protected function messages(): array
|
|||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
[
|
||||
'database.name.required' => 'The Name field is required.',
|
||||
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'database.mariadb_root_password.required' => 'The Root Password field is required.',
|
||||
'database.mariadb_user.required' => 'The MariaDB User field is required.',
|
||||
'database.mariadb_password.required' => 'The MariaDB Password field is required.',
|
||||
'database.mariadb_database.required' => 'The MariaDB Database field is required.',
|
||||
'database.image.required' => 'The Docker Image field is required.',
|
||||
'database.public_port.integer' => 'The Public Port must be an integer.',
|
||||
'name.required' => 'The Name field is required.',
|
||||
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'mariadbRootPassword.required' => 'The Root Password field is required.',
|
||||
'mariadbUser.required' => 'The MariaDB User field is required.',
|
||||
'mariadbPassword.required' => 'The MariaDB Password field is required.',
|
||||
'mariadbDatabase.required' => 'The MariaDB Database field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected $validationAttributes = [
|
||||
'database.name' => 'Name',
|
||||
'database.description' => 'Description',
|
||||
'database.mariadb_root_password' => 'Root Password',
|
||||
'database.mariadb_user' => 'User',
|
||||
'database.mariadb_password' => 'Password',
|
||||
'database.mariadb_database' => 'Database',
|
||||
'database.mariadb_conf' => 'MariaDB Configuration',
|
||||
'database.image' => 'Image',
|
||||
'database.ports_mappings' => 'Port Mapping',
|
||||
'database.is_public' => 'Is Public',
|
||||
'database.public_port' => 'Public Port',
|
||||
'database.custom_docker_run_options' => 'Custom Docker Options',
|
||||
'database.enable_ssl' => 'Enable SSL',
|
||||
'name' => 'Name',
|
||||
'description' => 'Description',
|
||||
'mariadbRootPassword' => 'Root Password',
|
||||
'mariadbUser' => 'User',
|
||||
'mariadbPassword' => 'Password',
|
||||
'mariadbDatabase' => 'Database',
|
||||
'mariadbConf' => 'MariaDB Configuration',
|
||||
'image' => 'Image',
|
||||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'customDockerRunOptions' => 'Custom Docker Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->database->name = $this->name;
|
||||
$this->database->description = $this->description;
|
||||
$this->database->mariadb_root_password = $this->mariadbRootPassword;
|
||||
$this->database->mariadb_user = $this->mariadbUser;
|
||||
$this->database->mariadb_password = $this->mariadbPassword;
|
||||
$this->database->mariadb_database = $this->mariadbDatabase;
|
||||
$this->database->mariadb_conf = $this->mariadbConf;
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
$this->database->save();
|
||||
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
} else {
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->mariadbRootPassword = $this->database->mariadb_root_password;
|
||||
$this->mariadbUser = $this->database->mariadb_user;
|
||||
$this->mariadbPassword = $this->database->mariadb_password;
|
||||
$this->mariadbDatabase = $this->database->mariadb_database;
|
||||
$this->mariadbConf = $this->database->mariadb_conf;
|
||||
$this->image = $this->database->image;
|
||||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,12 +189,12 @@ public function instantSaveAdvanced()
|
|||
$this->authorize('update', $this->database);
|
||||
|
||||
if (! $this->server->isLogDrainEnabled()) {
|
||||
$this->database->is_log_drain_enabled = false;
|
||||
$this->isLogDrainEnabled = false;
|
||||
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
|
||||
} catch (Exception $e) {
|
||||
|
|
@ -132,11 +207,10 @@ public function submit()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if (str($this->database->public_port)->isEmpty()) {
|
||||
$this->database->public_port = null;
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
$this->validate();
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -154,16 +228,16 @@ public function instantSave()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->database->is_public && ! $this->database->public_port) {
|
||||
if ($this->isPublic && ! $this->publicPort) {
|
||||
$this->dispatch('error', 'Public port is required.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->database->is_public) {
|
||||
if ($this->isPublic) {
|
||||
if (! str($this->database->status)->startsWith('running')) {
|
||||
$this->dispatch('error', 'Database must be started to be publicly accessible.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -173,10 +247,9 @@ public function instantSave()
|
|||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->database->is_public = ! $this->database->is_public;
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -187,7 +260,7 @@ public function instantSaveSSL()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'SSL configuration updated.');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -207,7 +280,7 @@ public function regenerateSslCertificate()
|
|||
return;
|
||||
}
|
||||
|
||||
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->common_name,
|
||||
|
|
@ -231,6 +304,7 @@ public function regenerateSslCertificate()
|
|||
public function refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -19,12 +18,38 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected $listeners = ['refresh'];
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneMongodb $database;
|
||||
|
||||
public string $name;
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
public ?string $mongoConf = null;
|
||||
|
||||
public string $mongoInitdbRootUsername;
|
||||
|
||||
public string $mongoInitdbRootPassword;
|
||||
|
||||
public string $mongoInitdbDatabase;
|
||||
|
||||
public string $image;
|
||||
|
||||
public ?string $portsMappings = null;
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public bool $enableSsl = false;
|
||||
|
||||
public ?string $sslMode = null;
|
||||
|
||||
public ?string $db_url = null;
|
||||
|
||||
public ?string $db_url_public = null;
|
||||
|
|
@ -37,27 +62,26 @@ public function getListeners()
|
|||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'refresh' => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'database.name' => ValidationPatterns::nameRules(),
|
||||
'database.description' => ValidationPatterns::descriptionRules(),
|
||||
'database.mongo_conf' => 'nullable',
|
||||
'database.mongo_initdb_root_username' => 'required',
|
||||
'database.mongo_initdb_root_password' => 'required',
|
||||
'database.mongo_initdb_database' => 'required',
|
||||
'database.image' => 'required',
|
||||
'database.ports_mappings' => 'nullable',
|
||||
'database.is_public' => 'nullable|boolean',
|
||||
'database.public_port' => 'nullable|integer',
|
||||
'database.is_log_drain_enabled' => 'nullable|boolean',
|
||||
'database.custom_docker_run_options' => 'nullable',
|
||||
'database.enable_ssl' => 'boolean',
|
||||
'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-full',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'mongoConf' => 'nullable',
|
||||
'mongoInitdbRootUsername' => 'required',
|
||||
'mongoInitdbRootPassword' => 'required',
|
||||
'mongoInitdbDatabase' => 'required',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -66,45 +90,96 @@ protected function messages(): array
|
|||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
[
|
||||
'database.name.required' => 'The Name field is required.',
|
||||
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'database.mongo_initdb_root_username.required' => 'The Root Username field is required.',
|
||||
'database.mongo_initdb_root_password.required' => 'The Root Password field is required.',
|
||||
'database.mongo_initdb_database.required' => 'The MongoDB Database field is required.',
|
||||
'database.image.required' => 'The Docker Image field is required.',
|
||||
'database.public_port.integer' => 'The Public Port must be an integer.',
|
||||
'database.ssl_mode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
|
||||
'name.required' => 'The Name field is required.',
|
||||
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
|
||||
'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
|
||||
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected $validationAttributes = [
|
||||
'database.name' => 'Name',
|
||||
'database.description' => 'Description',
|
||||
'database.mongo_conf' => 'Mongo Configuration',
|
||||
'database.mongo_initdb_root_username' => 'Root Username',
|
||||
'database.mongo_initdb_root_password' => 'Root Password',
|
||||
'database.mongo_initdb_database' => 'Database',
|
||||
'database.image' => 'Image',
|
||||
'database.ports_mappings' => 'Port Mapping',
|
||||
'database.is_public' => 'Is Public',
|
||||
'database.public_port' => 'Public Port',
|
||||
'database.custom_docker_run_options' => 'Custom Docker Run Options',
|
||||
'database.enable_ssl' => 'Enable SSL',
|
||||
'database.ssl_mode' => 'SSL Mode',
|
||||
'name' => 'Name',
|
||||
'description' => 'Description',
|
||||
'mongoConf' => 'Mongo Configuration',
|
||||
'mongoInitdbRootUsername' => 'Root Username',
|
||||
'mongoInitdbRootPassword' => 'Root Password',
|
||||
'mongoInitdbDatabase' => 'Database',
|
||||
'image' => 'Image',
|
||||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'customDockerRunOptions' => 'Custom Docker Run Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
'sslMode' => 'SSL Mode',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->database->name = $this->name;
|
||||
$this->database->description = $this->description;
|
||||
$this->database->mongo_conf = $this->mongoConf;
|
||||
$this->database->mongo_initdb_root_username = $this->mongoInitdbRootUsername;
|
||||
$this->database->mongo_initdb_root_password = $this->mongoInitdbRootPassword;
|
||||
$this->database->mongo_initdb_database = $this->mongoInitdbDatabase;
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
$this->database->ssl_mode = $this->sslMode;
|
||||
$this->database->save();
|
||||
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
} else {
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->mongoConf = $this->database->mongo_conf;
|
||||
$this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username;
|
||||
$this->mongoInitdbRootPassword = $this->database->mongo_initdb_root_password;
|
||||
$this->mongoInitdbDatabase = $this->database->mongo_initdb_database;
|
||||
$this->image = $this->database->image;
|
||||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
$this->sslMode = $this->database->ssl_mode;
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,12 +189,12 @@ public function instantSaveAdvanced()
|
|||
$this->authorize('update', $this->database);
|
||||
|
||||
if (! $this->server->isLogDrainEnabled()) {
|
||||
$this->database->is_log_drain_enabled = false;
|
||||
$this->isLogDrainEnabled = false;
|
||||
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
|
||||
} catch (Exception $e) {
|
||||
|
|
@ -132,14 +207,13 @@ public function submit()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if (str($this->database->public_port)->isEmpty()) {
|
||||
$this->database->public_port = null;
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
if (str($this->database->mongo_conf)->isEmpty()) {
|
||||
$this->database->mongo_conf = null;
|
||||
if (str($this->mongoConf)->isEmpty()) {
|
||||
$this->mongoConf = null;
|
||||
}
|
||||
$this->validate();
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -157,16 +231,16 @@ public function instantSave()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->database->is_public && ! $this->database->public_port) {
|
||||
if ($this->isPublic && ! $this->publicPort) {
|
||||
$this->dispatch('error', 'Public port is required.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->database->is_public) {
|
||||
if ($this->isPublic) {
|
||||
if (! str($this->database->status)->startsWith('running')) {
|
||||
$this->dispatch('error', 'Database must be started to be publicly accessible.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -176,16 +250,15 @@ public function instantSave()
|
|||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->database->is_public = ! $this->database->is_public;
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedDatabaseSslMode()
|
||||
public function updatedSslMode()
|
||||
{
|
||||
$this->instantSaveSSL();
|
||||
}
|
||||
|
|
@ -195,7 +268,7 @@ public function instantSaveSSL()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'SSL configuration updated.');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -215,7 +288,7 @@ public function regenerateSslCertificate()
|
|||
return;
|
||||
}
|
||||
|
||||
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->common_name,
|
||||
|
|
@ -239,6 +312,7 @@ public function regenerateSslCertificate()
|
|||
public function refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -19,11 +18,39 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected $listeners = ['refresh'];
|
||||
|
||||
public StandaloneMysql $database;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public string $name;
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
public string $mysqlRootPassword;
|
||||
|
||||
public string $mysqlUser;
|
||||
|
||||
public string $mysqlPassword;
|
||||
|
||||
public string $mysqlDatabase;
|
||||
|
||||
public ?string $mysqlConf = null;
|
||||
|
||||
public string $image;
|
||||
|
||||
public ?string $portsMappings = null;
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public bool $enableSsl = false;
|
||||
|
||||
public ?string $sslMode = null;
|
||||
|
||||
public ?string $db_url = null;
|
||||
|
||||
|
|
@ -37,28 +64,27 @@ public function getListeners()
|
|||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'refresh' => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'database.name' => ValidationPatterns::nameRules(),
|
||||
'database.description' => ValidationPatterns::descriptionRules(),
|
||||
'database.mysql_root_password' => 'required',
|
||||
'database.mysql_user' => 'required',
|
||||
'database.mysql_password' => 'required',
|
||||
'database.mysql_database' => 'required',
|
||||
'database.mysql_conf' => 'nullable',
|
||||
'database.image' => 'required',
|
||||
'database.ports_mappings' => 'nullable',
|
||||
'database.is_public' => 'nullable|boolean',
|
||||
'database.public_port' => 'nullable|integer',
|
||||
'database.is_log_drain_enabled' => 'nullable|boolean',
|
||||
'database.custom_docker_run_options' => 'nullable',
|
||||
'database.enable_ssl' => 'boolean',
|
||||
'database.ssl_mode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'mysqlRootPassword' => 'required',
|
||||
'mysqlUser' => 'required',
|
||||
'mysqlPassword' => 'required',
|
||||
'mysqlDatabase' => 'required',
|
||||
'mysqlConf' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -67,47 +93,100 @@ protected function messages(): array
|
|||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
[
|
||||
'database.name.required' => 'The Name field is required.',
|
||||
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'database.mysql_root_password.required' => 'The Root Password field is required.',
|
||||
'database.mysql_user.required' => 'The MySQL User field is required.',
|
||||
'database.mysql_password.required' => 'The MySQL Password field is required.',
|
||||
'database.mysql_database.required' => 'The MySQL Database field is required.',
|
||||
'database.image.required' => 'The Docker Image field is required.',
|
||||
'database.public_port.integer' => 'The Public Port must be an integer.',
|
||||
'database.ssl_mode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
|
||||
'name.required' => 'The Name field is required.',
|
||||
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'mysqlRootPassword.required' => 'The Root Password field is required.',
|
||||
'mysqlUser.required' => 'The MySQL User field is required.',
|
||||
'mysqlPassword.required' => 'The MySQL Password field is required.',
|
||||
'mysqlDatabase.required' => 'The MySQL Database field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected $validationAttributes = [
|
||||
'database.name' => 'Name',
|
||||
'database.description' => 'Description',
|
||||
'database.mysql_root_password' => 'Root Password',
|
||||
'database.mysql_user' => 'User',
|
||||
'database.mysql_password' => 'Password',
|
||||
'database.mysql_database' => 'Database',
|
||||
'database.mysql_conf' => 'MySQL Configuration',
|
||||
'database.image' => 'Image',
|
||||
'database.ports_mappings' => 'Port Mapping',
|
||||
'database.is_public' => 'Is Public',
|
||||
'database.public_port' => 'Public Port',
|
||||
'database.custom_docker_run_options' => 'Custom Docker Run Options',
|
||||
'database.enable_ssl' => 'Enable SSL',
|
||||
'database.ssl_mode' => 'SSL Mode',
|
||||
'name' => 'Name',
|
||||
'description' => 'Description',
|
||||
'mysqlRootPassword' => 'Root Password',
|
||||
'mysqlUser' => 'User',
|
||||
'mysqlPassword' => 'Password',
|
||||
'mysqlDatabase' => 'Database',
|
||||
'mysqlConf' => 'MySQL Configuration',
|
||||
'image' => 'Image',
|
||||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'customDockerRunOptions' => 'Custom Docker Run Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
'sslMode' => 'SSL Mode',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->database->name = $this->name;
|
||||
$this->database->description = $this->description;
|
||||
$this->database->mysql_root_password = $this->mysqlRootPassword;
|
||||
$this->database->mysql_user = $this->mysqlUser;
|
||||
$this->database->mysql_password = $this->mysqlPassword;
|
||||
$this->database->mysql_database = $this->mysqlDatabase;
|
||||
$this->database->mysql_conf = $this->mysqlConf;
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
$this->database->ssl_mode = $this->sslMode;
|
||||
$this->database->save();
|
||||
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
} else {
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->mysqlRootPassword = $this->database->mysql_root_password;
|
||||
$this->mysqlUser = $this->database->mysql_user;
|
||||
$this->mysqlPassword = $this->database->mysql_password;
|
||||
$this->mysqlDatabase = $this->database->mysql_database;
|
||||
$this->mysqlConf = $this->database->mysql_conf;
|
||||
$this->image = $this->database->image;
|
||||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
$this->sslMode = $this->database->ssl_mode;
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,12 +196,12 @@ public function instantSaveAdvanced()
|
|||
$this->authorize('update', $this->database);
|
||||
|
||||
if (! $this->server->isLogDrainEnabled()) {
|
||||
$this->database->is_log_drain_enabled = false;
|
||||
$this->isLogDrainEnabled = false;
|
||||
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
|
||||
} catch (Exception $e) {
|
||||
|
|
@ -135,11 +214,10 @@ public function submit()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if (str($this->database->public_port)->isEmpty()) {
|
||||
$this->database->public_port = null;
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
$this->validate();
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -157,16 +235,16 @@ public function instantSave()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->database->is_public && ! $this->database->public_port) {
|
||||
if ($this->isPublic && ! $this->publicPort) {
|
||||
$this->dispatch('error', 'Public port is required.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->database->is_public) {
|
||||
if ($this->isPublic) {
|
||||
if (! str($this->database->status)->startsWith('running')) {
|
||||
$this->dispatch('error', 'Database must be started to be publicly accessible.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -176,16 +254,15 @@ public function instantSave()
|
|||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->database->is_public = ! $this->database->is_public;
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedDatabaseSslMode()
|
||||
public function updatedSslMode()
|
||||
{
|
||||
$this->instantSaveSSL();
|
||||
}
|
||||
|
|
@ -195,7 +272,7 @@ public function instantSaveSSL()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'SSL configuration updated.');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -215,7 +292,7 @@ public function regenerateSslCertificate()
|
|||
return;
|
||||
}
|
||||
|
||||
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->common_name,
|
||||
|
|
@ -239,6 +316,7 @@ public function regenerateSslCertificate()
|
|||
public function refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -21,7 +20,41 @@ class General extends Component
|
|||
|
||||
public StandalonePostgresql $database;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public string $name;
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
public string $postgresUser;
|
||||
|
||||
public string $postgresPassword;
|
||||
|
||||
public string $postgresDb;
|
||||
|
||||
public ?string $postgresInitdbArgs = null;
|
||||
|
||||
public ?string $postgresHostAuthMethod = null;
|
||||
|
||||
public ?string $postgresConf = null;
|
||||
|
||||
public ?array $initScripts = null;
|
||||
|
||||
public string $image;
|
||||
|
||||
public ?string $portsMappings = null;
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public bool $enableSsl = false;
|
||||
|
||||
public ?string $sslMode = null;
|
||||
|
||||
public string $new_filename;
|
||||
|
||||
|
|
@ -39,7 +72,6 @@ public function getListeners()
|
|||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'refresh' => '$refresh',
|
||||
'save_init_script',
|
||||
'delete_init_script',
|
||||
];
|
||||
|
|
@ -48,23 +80,23 @@ public function getListeners()
|
|||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'database.name' => ValidationPatterns::nameRules(),
|
||||
'database.description' => ValidationPatterns::descriptionRules(),
|
||||
'database.postgres_user' => 'required',
|
||||
'database.postgres_password' => 'required',
|
||||
'database.postgres_db' => 'required',
|
||||
'database.postgres_initdb_args' => 'nullable',
|
||||
'database.postgres_host_auth_method' => 'nullable',
|
||||
'database.postgres_conf' => 'nullable',
|
||||
'database.init_scripts' => 'nullable',
|
||||
'database.image' => 'required',
|
||||
'database.ports_mappings' => 'nullable',
|
||||
'database.is_public' => 'nullable|boolean',
|
||||
'database.public_port' => 'nullable|integer',
|
||||
'database.is_log_drain_enabled' => 'nullable|boolean',
|
||||
'database.custom_docker_run_options' => 'nullable',
|
||||
'database.enable_ssl' => 'boolean',
|
||||
'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'postgresUser' => 'required',
|
||||
'postgresPassword' => 'required',
|
||||
'postgresDb' => 'required',
|
||||
'postgresInitdbArgs' => 'nullable',
|
||||
'postgresHostAuthMethod' => 'nullable',
|
||||
'postgresConf' => 'nullable',
|
||||
'initScripts' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -73,48 +105,105 @@ protected function messages(): array
|
|||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
[
|
||||
'database.name.required' => 'The Name field is required.',
|
||||
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'database.postgres_user.required' => 'The Postgres User field is required.',
|
||||
'database.postgres_password.required' => 'The Postgres Password field is required.',
|
||||
'database.postgres_db.required' => 'The Postgres Database field is required.',
|
||||
'database.image.required' => 'The Docker Image field is required.',
|
||||
'database.public_port.integer' => 'The Public Port must be an integer.',
|
||||
'database.ssl_mode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
|
||||
'name.required' => 'The Name field is required.',
|
||||
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'postgresUser.required' => 'The Postgres User field is required.',
|
||||
'postgresPassword.required' => 'The Postgres Password field is required.',
|
||||
'postgresDb.required' => 'The Postgres Database field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected $validationAttributes = [
|
||||
'database.name' => 'Name',
|
||||
'database.description' => 'Description',
|
||||
'database.postgres_user' => 'Postgres User',
|
||||
'database.postgres_password' => 'Postgres Password',
|
||||
'database.postgres_db' => 'Postgres DB',
|
||||
'database.postgres_initdb_args' => 'Postgres Initdb Args',
|
||||
'database.postgres_host_auth_method' => 'Postgres Host Auth Method',
|
||||
'database.postgres_conf' => 'Postgres Configuration',
|
||||
'database.init_scripts' => 'Init Scripts',
|
||||
'database.image' => 'Image',
|
||||
'database.ports_mappings' => 'Port Mapping',
|
||||
'database.is_public' => 'Is Public',
|
||||
'database.public_port' => 'Public Port',
|
||||
'database.custom_docker_run_options' => 'Custom Docker Run Options',
|
||||
'database.enable_ssl' => 'Enable SSL',
|
||||
'database.ssl_mode' => 'SSL Mode',
|
||||
'name' => 'Name',
|
||||
'description' => 'Description',
|
||||
'postgresUser' => 'Postgres User',
|
||||
'postgresPassword' => 'Postgres Password',
|
||||
'postgresDb' => 'Postgres DB',
|
||||
'postgresInitdbArgs' => 'Postgres Initdb Args',
|
||||
'postgresHostAuthMethod' => 'Postgres Host Auth Method',
|
||||
'postgresConf' => 'Postgres Configuration',
|
||||
'initScripts' => 'Init Scripts',
|
||||
'image' => 'Image',
|
||||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'customDockerRunOptions' => 'Custom Docker Run Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
'sslMode' => 'SSL Mode',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->database->name = $this->name;
|
||||
$this->database->description = $this->description;
|
||||
$this->database->postgres_user = $this->postgresUser;
|
||||
$this->database->postgres_password = $this->postgresPassword;
|
||||
$this->database->postgres_db = $this->postgresDb;
|
||||
$this->database->postgres_initdb_args = $this->postgresInitdbArgs;
|
||||
$this->database->postgres_host_auth_method = $this->postgresHostAuthMethod;
|
||||
$this->database->postgres_conf = $this->postgresConf;
|
||||
$this->database->init_scripts = $this->initScripts;
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
$this->database->ssl_mode = $this->sslMode;
|
||||
$this->database->save();
|
||||
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
} else {
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->postgresUser = $this->database->postgres_user;
|
||||
$this->postgresPassword = $this->database->postgres_password;
|
||||
$this->postgresDb = $this->database->postgres_db;
|
||||
$this->postgresInitdbArgs = $this->database->postgres_initdb_args;
|
||||
$this->postgresHostAuthMethod = $this->database->postgres_host_auth_method;
|
||||
$this->postgresConf = $this->database->postgres_conf;
|
||||
$this->initScripts = $this->database->init_scripts;
|
||||
$this->image = $this->database->image;
|
||||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
$this->sslMode = $this->database->ssl_mode;
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,12 +213,12 @@ public function instantSaveAdvanced()
|
|||
$this->authorize('update', $this->database);
|
||||
|
||||
if (! $this->server->isLogDrainEnabled()) {
|
||||
$this->database->is_log_drain_enabled = false;
|
||||
$this->isLogDrainEnabled = false;
|
||||
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
|
||||
} catch (Exception $e) {
|
||||
|
|
@ -137,7 +226,7 @@ public function instantSaveAdvanced()
|
|||
}
|
||||
}
|
||||
|
||||
public function updatedDatabaseSslMode()
|
||||
public function updatedSslMode()
|
||||
{
|
||||
$this->instantSaveSSL();
|
||||
}
|
||||
|
|
@ -147,10 +236,8 @@ public function instantSaveSSL()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'SSL configuration updated.');
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -169,7 +256,7 @@ public function regenerateSslCertificate()
|
|||
return;
|
||||
}
|
||||
|
||||
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->common_name,
|
||||
|
|
@ -195,16 +282,16 @@ public function instantSave()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->database->is_public && ! $this->database->public_port) {
|
||||
if ($this->isPublic && ! $this->publicPort) {
|
||||
$this->dispatch('error', 'Public port is required.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->database->is_public) {
|
||||
if ($this->isPublic) {
|
||||
if (! str($this->database->status)->startsWith('running')) {
|
||||
$this->dispatch('error', 'Database must be started to be publicly accessible.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -214,10 +301,9 @@ public function instantSave()
|
|||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->database->is_public = ! $this->database->is_public;
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -227,7 +313,7 @@ public function save_init_script($script)
|
|||
{
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
$initScripts = collect($this->database->init_scripts ?? []);
|
||||
$initScripts = collect($this->initScripts ?? []);
|
||||
|
||||
$existingScript = $initScripts->firstWhere('filename', $script['filename']);
|
||||
$oldScript = $initScripts->firstWhere('index', $script['index']);
|
||||
|
|
@ -263,7 +349,7 @@ public function save_init_script($script)
|
|||
$initScripts->push($script);
|
||||
}
|
||||
|
||||
$this->database->init_scripts = $initScripts->values()
|
||||
$this->initScripts = $initScripts->values()
|
||||
->map(function ($item, $index) {
|
||||
$item['index'] = $index;
|
||||
|
||||
|
|
@ -271,7 +357,7 @@ public function save_init_script($script)
|
|||
})
|
||||
->all();
|
||||
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Init script saved and updated.');
|
||||
}
|
||||
|
||||
|
|
@ -279,7 +365,7 @@ public function delete_init_script($script)
|
|||
{
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
$collection = collect($this->database->init_scripts);
|
||||
$collection = collect($this->initScripts);
|
||||
$found = $collection->firstWhere('filename', $script['filename']);
|
||||
if ($found) {
|
||||
$container_name = $this->database->uuid;
|
||||
|
|
@ -304,8 +390,8 @@ public function delete_init_script($script)
|
|||
})
|
||||
->all();
|
||||
|
||||
$this->database->init_scripts = $updatedScripts;
|
||||
$this->database->save();
|
||||
$this->initScripts = $updatedScripts;
|
||||
$this->syncData(true);
|
||||
$this->dispatch('refresh')->self();
|
||||
$this->dispatch('success', 'Init script deleted from the database and the server.');
|
||||
}
|
||||
|
|
@ -319,23 +405,23 @@ public function save_new_init_script()
|
|||
'new_filename' => 'required|string',
|
||||
'new_content' => 'required|string',
|
||||
]);
|
||||
$found = collect($this->database->init_scripts)->firstWhere('filename', $this->new_filename);
|
||||
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
|
||||
if ($found) {
|
||||
$this->dispatch('error', 'Filename already exists.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! isset($this->database->init_scripts)) {
|
||||
$this->database->init_scripts = [];
|
||||
if (! isset($this->initScripts)) {
|
||||
$this->initScripts = [];
|
||||
}
|
||||
$this->database->init_scripts = array_merge($this->database->init_scripts, [
|
||||
$this->initScripts = array_merge($this->initScripts, [
|
||||
[
|
||||
'index' => count($this->database->init_scripts),
|
||||
'index' => count($this->initScripts),
|
||||
'filename' => $this->new_filename,
|
||||
'content' => $this->new_content,
|
||||
],
|
||||
]);
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Init script added.');
|
||||
$this->new_content = '';
|
||||
$this->new_filename = '';
|
||||
|
|
@ -346,11 +432,10 @@ public function submit()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if (str($this->database->public_port)->isEmpty()) {
|
||||
$this->database->public_port = null;
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
$this->validate();
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -19,19 +18,39 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneRedis $database;
|
||||
|
||||
public string $redis_username;
|
||||
public string $name;
|
||||
|
||||
public ?string $redis_password;
|
||||
public ?string $description = null;
|
||||
|
||||
public string $redis_version;
|
||||
public ?string $redisConf = null;
|
||||
|
||||
public ?string $db_url = null;
|
||||
public string $image;
|
||||
|
||||
public ?string $db_url_public = null;
|
||||
public ?string $portsMappings = null;
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public string $redisUsername;
|
||||
|
||||
public string $redisPassword;
|
||||
|
||||
public string $redisVersion;
|
||||
|
||||
public ?string $dbUrl = null;
|
||||
|
||||
public ?string $dbUrlPublic = null;
|
||||
|
||||
public bool $enableSsl = false;
|
||||
|
||||
public ?Carbon $certificateValidUntil = null;
|
||||
|
||||
|
|
@ -42,25 +61,24 @@ public function getListeners()
|
|||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'envsUpdated' => 'refresh',
|
||||
'refresh',
|
||||
];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'database.name' => ValidationPatterns::nameRules(),
|
||||
'database.description' => ValidationPatterns::descriptionRules(),
|
||||
'database.redis_conf' => 'nullable',
|
||||
'database.image' => 'required',
|
||||
'database.ports_mappings' => 'nullable',
|
||||
'database.is_public' => 'nullable|boolean',
|
||||
'database.public_port' => 'nullable|integer',
|
||||
'database.is_log_drain_enabled' => 'nullable|boolean',
|
||||
'database.custom_docker_run_options' => 'nullable',
|
||||
'redis_username' => 'required',
|
||||
'redis_password' => 'required',
|
||||
'database.enable_ssl' => 'boolean',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'redisConf' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'redisUsername' => 'required',
|
||||
'redisPassword' => 'required',
|
||||
'enableSsl' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -69,39 +87,87 @@ protected function messages(): array
|
|||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
[
|
||||
'database.name.required' => 'The Name field is required.',
|
||||
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'database.image.required' => 'The Docker Image field is required.',
|
||||
'database.public_port.integer' => 'The Public Port must be an integer.',
|
||||
'redis_username.required' => 'The Redis Username field is required.',
|
||||
'redis_password.required' => 'The Redis Password field is required.',
|
||||
'name.required' => 'The Name field is required.',
|
||||
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'redisUsername.required' => 'The Redis Username field is required.',
|
||||
'redisPassword.required' => 'The Redis Password field is required.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected $validationAttributes = [
|
||||
'database.name' => 'Name',
|
||||
'database.description' => 'Description',
|
||||
'database.redis_conf' => 'Redis Configuration',
|
||||
'database.image' => 'Image',
|
||||
'database.ports_mappings' => 'Port Mapping',
|
||||
'database.is_public' => 'Is Public',
|
||||
'database.public_port' => 'Public Port',
|
||||
'database.custom_docker_run_options' => 'Custom Docker Options',
|
||||
'redis_username' => 'Redis Username',
|
||||
'redis_password' => 'Redis Password',
|
||||
'database.enable_ssl' => 'Enable SSL',
|
||||
'name' => 'Name',
|
||||
'description' => 'Description',
|
||||
'redisConf' => 'Redis Configuration',
|
||||
'image' => 'Image',
|
||||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'customDockerRunOptions' => 'Custom Docker Options',
|
||||
'redisUsername' => 'Redis Username',
|
||||
'redisPassword' => 'Redis Password',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
$this->refreshView();
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->database->name = $this->name;
|
||||
$this->database->description = $this->description;
|
||||
$this->database->redis_conf = $this->redisConf;
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
$this->database->save();
|
||||
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
} else {
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->redisConf = $this->database->redis_conf;
|
||||
$this->image = $this->database->image;
|
||||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
$this->redisVersion = $this->database->getRedisVersion();
|
||||
$this->redisUsername = $this->database->redis_username;
|
||||
$this->redisPassword = $this->database->redis_password;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,12 +177,12 @@ public function instantSaveAdvanced()
|
|||
$this->authorize('update', $this->database);
|
||||
|
||||
if (! $this->server->isLogDrainEnabled()) {
|
||||
$this->database->is_log_drain_enabled = false;
|
||||
$this->isLogDrainEnabled = false;
|
||||
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
|
||||
} catch (Exception $e) {
|
||||
|
|
@ -129,20 +195,19 @@ public function submit()
|
|||
try {
|
||||
$this->authorize('manageEnvironment', $this->database);
|
||||
|
||||
$this->validate();
|
||||
$this->syncData(true);
|
||||
|
||||
if (version_compare($this->redis_version, '6.0', '>=')) {
|
||||
if (version_compare($this->redisVersion, '6.0', '>=')) {
|
||||
$this->database->runtime_environment_variables()->updateOrCreate(
|
||||
['key' => 'REDIS_USERNAME'],
|
||||
['value' => $this->redis_username, 'resourceable_id' => $this->database->id]
|
||||
['value' => $this->redisUsername, 'resourceable_id' => $this->database->id]
|
||||
);
|
||||
}
|
||||
$this->database->runtime_environment_variables()->updateOrCreate(
|
||||
['key' => 'REDIS_PASSWORD'],
|
||||
['value' => $this->redis_password, 'resourceable_id' => $this->database->id]
|
||||
['value' => $this->redisPassword, 'resourceable_id' => $this->database->id]
|
||||
);
|
||||
|
||||
$this->database->save();
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -156,16 +221,16 @@ public function instantSave()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->database->is_public && ! $this->database->public_port) {
|
||||
if ($this->isPublic && ! $this->publicPort) {
|
||||
$this->dispatch('error', 'Public port is required.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->database->is_public) {
|
||||
if ($this->isPublic) {
|
||||
if (! str($this->database->status)->startsWith('running')) {
|
||||
$this->dispatch('error', 'Database must be started to be publicly accessible.');
|
||||
$this->database->is_public = false;
|
||||
$this->isPublic = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -175,10 +240,11 @@ public function instantSave()
|
|||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->database->save();
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->database->is_public = ! $this->database->is_public;
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -189,7 +255,7 @@ public function instantSaveSSL()
|
|||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
$this->database->save();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'SSL configuration updated.');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -209,7 +275,7 @@ public function regenerateSslCertificate()
|
|||
return;
|
||||
}
|
||||
|
||||
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->commonName,
|
||||
|
|
@ -233,16 +299,7 @@ public function regenerateSslCertificate()
|
|||
public function refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
$this->refreshView();
|
||||
}
|
||||
|
||||
private function refreshView()
|
||||
{
|
||||
$this->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
$this->redis_version = $this->database->getRedisVersion();
|
||||
$this->redis_username = $this->database->redis_username;
|
||||
$this->redis_password = $this->database->redis_password;
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -28,18 +28,60 @@ public function mount()
|
|||
$this->query = request()->query();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-parse image name when user pastes a complete Docker image reference
|
||||
* Examples:
|
||||
* - nginx:stable-alpine3.21-perl@sha256:4e272eef...
|
||||
* - ghcr.io/user/app:v1.2.3
|
||||
* - nginx@sha256:abc123...
|
||||
*/
|
||||
public function updatedImageName(): void
|
||||
{
|
||||
if (empty($this->imageName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't auto-parse if user has already manually filled tag or sha256 fields
|
||||
if (! empty($this->imageTag) || ! empty($this->imageSha256)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only auto-parse if the image name contains a tag (:) or digest (@)
|
||||
if (! str_contains($this->imageName, ':') && ! str_contains($this->imageName, '@')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($this->imageName);
|
||||
|
||||
// Extract the base image name (without tag/digest)
|
||||
$baseImageName = $parser->getFullImageNameWithoutTag();
|
||||
|
||||
// Only update if parsing resulted in different base name
|
||||
// This prevents unnecessary updates when user types just the name
|
||||
if ($baseImageName !== $this->imageName) {
|
||||
if ($parser->isImageHash()) {
|
||||
// It's a SHA256 digest (takes priority over tag)
|
||||
$this->imageSha256 = $parser->getTag();
|
||||
$this->imageTag = '';
|
||||
} elseif ($parser->getTag() !== 'latest' || str_contains($this->imageName, ':')) {
|
||||
// It's a regular tag (only set if not default 'latest' or explicitly specified)
|
||||
$this->imageTag = $parser->getTag();
|
||||
$this->imageSha256 = '';
|
||||
}
|
||||
|
||||
// Update imageName to just the base name
|
||||
$this->imageName = $baseImageName;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If parsing fails, leave the image name as-is
|
||||
// User will see validation error on submit
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
// Strip 'sha256:' prefix if user pasted it
|
||||
if ($this->imageSha256) {
|
||||
$this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
|
||||
}
|
||||
|
||||
// Remove @sha256 from image name if user added it
|
||||
if ($this->imageName) {
|
||||
$this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName));
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'imageName' => ['required', 'string'],
|
||||
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
|
||||
|
|
@ -56,13 +98,16 @@ public function submit()
|
|||
|
||||
// Build the full Docker image string
|
||||
if ($this->imageSha256) {
|
||||
$dockerImage = $this->imageName.'@sha256:'.$this->imageSha256;
|
||||
// Strip 'sha256:' prefix if user pasted it
|
||||
$sha256Hash = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
|
||||
$dockerImage = $this->imageName.'@sha256:'.$sha256Hash;
|
||||
} elseif ($this->imageTag) {
|
||||
$dockerImage = $this->imageName.':'.$this->imageTag;
|
||||
} else {
|
||||
$dockerImage = $this->imageName.':latest';
|
||||
}
|
||||
|
||||
// Parse using DockerImageParser to normalize the image reference
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($dockerImage);
|
||||
|
||||
|
|
@ -79,15 +124,15 @@ public function submit()
|
|||
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
|
||||
// Determine the image tag based on whether it's a hash or regular tag
|
||||
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
|
||||
|
||||
// Append @sha256 to image name if using digest and not already present
|
||||
$imageName = $parser->getFullImageNameWithoutTag();
|
||||
if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
|
||||
$imageName .= '@sha256';
|
||||
}
|
||||
|
||||
// Determine the image tag based on whether it's a hash or regular tag
|
||||
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
|
||||
|
||||
$application = Application::create([
|
||||
'name' => 'docker-image-'.new Cuid2,
|
||||
'repository_project_id' => 0,
|
||||
|
|
@ -96,7 +141,7 @@ public function submit()
|
|||
'build_pack' => 'dockerimage',
|
||||
'ports_exposes' => 80,
|
||||
'docker_registry_image_name' => $imageName,
|
||||
'docker_registry_image_tag' => $parser->getTag(),
|
||||
'docker_registry_image_tag' => $imageTag,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination_class,
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ public function mount()
|
|||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
if ($oneClickServiceName === 'cloudflared') {
|
||||
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
|
||||
data_set($service_payload, 'connect_to_docker_network', true);
|
||||
}
|
||||
$service = Service::create($service_payload);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ public function getListeners()
|
|||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
|
||||
'refreshServices' => 'refreshServices',
|
||||
'refresh' => 'refreshServices',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ public function submit()
|
|||
}
|
||||
$this->application->service->parse();
|
||||
$this->dispatch('refresh');
|
||||
$this->dispatch('refreshServices');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
$originalFqdn = $this->application->getOriginal('fqdn');
|
||||
|
|
|
|||
101
app/Livewire/Security/CloudInitScriptForm.php
Normal file
101
app/Livewire/Security/CloudInitScriptForm.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\CloudInitScript;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class CloudInitScriptForm extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public bool $modal_mode = true;
|
||||
|
||||
public ?int $scriptId = null;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $script = '';
|
||||
|
||||
public function mount(?int $scriptId = null)
|
||||
{
|
||||
if ($scriptId) {
|
||||
$this->scriptId = $scriptId;
|
||||
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
|
||||
$this->authorize('update', $cloudInitScript);
|
||||
|
||||
$this->name = $cloudInitScript->name;
|
||||
$this->script = $cloudInitScript->script;
|
||||
} else {
|
||||
$this->authorize('create', CloudInitScript::class);
|
||||
}
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml],
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'Script name is required.',
|
||||
'name.max' => 'Script name cannot exceed 255 characters.',
|
||||
'script.required' => 'Cloud-init script content is required.',
|
||||
];
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
if ($this->scriptId) {
|
||||
// Update existing script
|
||||
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($this->scriptId);
|
||||
$this->authorize('update', $cloudInitScript);
|
||||
|
||||
$cloudInitScript->update([
|
||||
'name' => $this->name,
|
||||
'script' => $this->script,
|
||||
]);
|
||||
|
||||
$message = 'Cloud-init script updated successfully.';
|
||||
} else {
|
||||
// Create new script
|
||||
$this->authorize('create', CloudInitScript::class);
|
||||
|
||||
CloudInitScript::create([
|
||||
'team_id' => currentTeam()->id,
|
||||
'name' => $this->name,
|
||||
'script' => $this->script,
|
||||
]);
|
||||
|
||||
$message = 'Cloud-init script created successfully.';
|
||||
}
|
||||
|
||||
// Only reset fields if creating (not editing)
|
||||
if (! $this->scriptId) {
|
||||
$this->reset(['name', 'script']);
|
||||
}
|
||||
|
||||
$this->dispatch('scriptSaved');
|
||||
$this->dispatch('success', $message);
|
||||
|
||||
if ($this->modal_mode) {
|
||||
$this->dispatch('closeModal');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.cloud-init-script-form');
|
||||
}
|
||||
}
|
||||
52
app/Livewire/Security/CloudInitScripts.php
Normal file
52
app/Livewire/Security/CloudInitScripts.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\CloudInitScript;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class CloudInitScripts extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $scripts;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('viewAny', CloudInitScript::class);
|
||||
$this->loadScripts();
|
||||
}
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'scriptSaved' => 'loadScripts',
|
||||
];
|
||||
}
|
||||
|
||||
public function loadScripts()
|
||||
{
|
||||
$this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get();
|
||||
}
|
||||
|
||||
public function deleteScript(int $scriptId)
|
||||
{
|
||||
try {
|
||||
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
|
||||
$this->authorize('delete', $script);
|
||||
|
||||
$script->delete();
|
||||
$this->loadScripts();
|
||||
|
||||
$this->dispatch('success', 'Cloud-init script deleted successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.cloud-init-scripts');
|
||||
}
|
||||
}
|
||||
99
app/Livewire/Security/CloudProviderTokenForm.php
Normal file
99
app/Livewire/Security/CloudProviderTokenForm.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Component;
|
||||
|
||||
class CloudProviderTokenForm extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public bool $modal_mode = false;
|
||||
|
||||
public string $provider = 'hetzner';
|
||||
|
||||
public string $token = '';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('create', CloudProviderToken::class);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'provider' => 'required|string|in:hetzner,digitalocean',
|
||||
'token' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'provider.required' => 'Please select a cloud provider.',
|
||||
'provider.in' => 'Invalid cloud provider selected.',
|
||||
'token.required' => 'API token is required.',
|
||||
'name.required' => 'Token name is required.',
|
||||
];
|
||||
}
|
||||
|
||||
private function validateToken(string $provider, string $token): bool
|
||||
{
|
||||
try {
|
||||
if ($provider === 'hetzner') {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
|
||||
ray($response);
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
// Add other providers here in the future
|
||||
// if ($provider === 'digitalocean') { ... }
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function addToken()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
// Validate the token with the provider's API
|
||||
if (! $this->validateToken($this->provider, $this->token)) {
|
||||
return $this->dispatch('error', 'Invalid API token. Please check your token and try again.');
|
||||
}
|
||||
|
||||
$savedToken = CloudProviderToken::create([
|
||||
'team_id' => currentTeam()->id,
|
||||
'provider' => $this->provider,
|
||||
'token' => $this->token,
|
||||
'name' => $this->name,
|
||||
]);
|
||||
|
||||
$this->reset(['token', 'name']);
|
||||
|
||||
// Dispatch event with token ID so parent components can react
|
||||
$this->dispatch('tokenAdded', tokenId: $savedToken->id);
|
||||
|
||||
$this->dispatch('success', 'Cloud provider token added successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.cloud-provider-token-form');
|
||||
}
|
||||
}
|
||||
60
app/Livewire/Security/CloudProviderTokens.php
Normal file
60
app/Livewire/Security/CloudProviderTokens.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class CloudProviderTokens extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $tokens;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->loadTokens();
|
||||
}
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'tokenAdded' => 'loadTokens',
|
||||
];
|
||||
}
|
||||
|
||||
public function loadTokens()
|
||||
{
|
||||
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
|
||||
}
|
||||
|
||||
public function deleteToken(int $tokenId)
|
||||
{
|
||||
try {
|
||||
$token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
|
||||
$this->authorize('delete', $token);
|
||||
|
||||
// Check if any servers are using this token
|
||||
if ($token->hasServers()) {
|
||||
$serverCount = $token->servers()->count();
|
||||
$this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$token->delete();
|
||||
$this->loadTokens();
|
||||
|
||||
$this->dispatch('success', 'Cloud provider token deleted successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.cloud-provider-tokens');
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Security/CloudTokens.php
Normal file
13
app/Livewire/Security/CloudTokens.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class CloudTokens extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.cloud-tokens');
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ class Create extends Component
|
|||
|
||||
public ?string $publicKey = null;
|
||||
|
||||
public bool $modal_mode = false;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -77,6 +79,14 @@ public function createPrivateKey()
|
|||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
// If in modal mode, dispatch event and don't redirect
|
||||
if ($this->modal_mode) {
|
||||
$this->dispatch('privateKeyCreated', keyId: $privateKey->id);
|
||||
$this->dispatch('success', 'Private key created successfully.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return $this->redirectAfterCreation($privateKey);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public function mount(string $server_uuid)
|
|||
|
||||
public function loadCaCertificate()
|
||||
{
|
||||
$this->caCertificate = SslCertificate::where('server_id', $this->server->id)->where('is_ca_certificate', true)->first();
|
||||
$this->caCertificate = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if ($this->caCertificate) {
|
||||
$this->certificateContent = $this->caCertificate->ssl_certificate;
|
||||
|
|
|
|||
144
app/Livewire/Server/CloudProviderToken/Show.php
Normal file
144
app/Livewire/Server/CloudProviderToken/Show.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Server\CloudProviderToken;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public $cloudProviderTokens = [];
|
||||
|
||||
public $parameters = [];
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->loadTokens();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'tokenAdded' => 'handleTokenAdded',
|
||||
];
|
||||
}
|
||||
|
||||
public function loadTokens()
|
||||
{
|
||||
$this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
->where('provider', 'hetzner')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function handleTokenAdded($tokenId)
|
||||
{
|
||||
$this->loadTokens();
|
||||
}
|
||||
|
||||
public function setCloudProviderToken($tokenId)
|
||||
{
|
||||
$ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId);
|
||||
if (is_null($ownedToken)) {
|
||||
$this->dispatch('error', 'You are not allowed to use this token.');
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
// Validate the token works and can access this specific server
|
||||
$validationResult = $this->validateTokenForServer($ownedToken);
|
||||
if (! $validationResult['valid']) {
|
||||
$this->dispatch('error', $validationResult['error']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->server->cloudProviderToken()->associate($ownedToken);
|
||||
$this->server->save();
|
||||
$this->dispatch('success', 'Hetzner token updated successfully.');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Exception $e) {
|
||||
$this->server->refresh();
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function validateTokenForServer(CloudProviderToken $token): array
|
||||
{
|
||||
try {
|
||||
// First, validate the token itself
|
||||
$response = \Illuminate\Support\Facades\Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'This token is invalid or has insufficient permissions.',
|
||||
];
|
||||
}
|
||||
|
||||
// Check if this token can access the specific Hetzner server
|
||||
if ($this->server->hetzner_server_id) {
|
||||
$serverResponse = \Illuminate\Support\Facades\Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->token,
|
||||
])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}");
|
||||
|
||||
if (! $serverResponse->successful()) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'This token cannot access this server. It may belong to a different Hetzner project.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['valid' => true];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'Failed to validate token: '.$e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function validateToken()
|
||||
{
|
||||
try {
|
||||
$token = $this->server->cloudProviderToken;
|
||||
if (! $token) {
|
||||
$this->dispatch('error', 'No Hetzner token is associated with this server.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
|
||||
|
||||
if ($response->successful()) {
|
||||
$this->dispatch('success', 'Hetzner token is valid and working.');
|
||||
} else {
|
||||
$this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.cloud-provider-token.show');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use Livewire\Component;
|
||||
|
|
@ -12,6 +13,8 @@ class Create extends Component
|
|||
|
||||
public bool $limit_reached = false;
|
||||
|
||||
public bool $has_hetzner_tokens = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
|
||||
|
|
@ -21,6 +24,11 @@ public function mount()
|
|||
return;
|
||||
}
|
||||
$this->limit_reached = Team::serverLimitReached();
|
||||
|
||||
// Check if user has Hetzner tokens
|
||||
$this->has_hetzner_tokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
->where('provider', 'hetzner')
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ class Delete extends Component
|
|||
|
||||
public Server $server;
|
||||
|
||||
public bool $delete_from_hetzner = false;
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
|
|
@ -41,8 +43,15 @@ public function delete($password)
|
|||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->server->delete();
|
||||
DeleteServer::dispatch($this->server);
|
||||
DeleteServer::dispatch(
|
||||
$this->server->id,
|
||||
$this->delete_from_hetzner,
|
||||
$this->server->hetzner_server_id,
|
||||
$this->server->cloud_provider_token_id,
|
||||
$this->server->team_id
|
||||
);
|
||||
|
||||
return redirect()->route('server.index');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -52,6 +61,18 @@ public function delete($password)
|
|||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.delete');
|
||||
$checkboxes = [];
|
||||
|
||||
if ($this->server->hetzner_server_id) {
|
||||
$checkboxes[] = [
|
||||
'id' => 'delete_from_hetzner',
|
||||
'label' => 'Also delete server from Hetzner Cloud',
|
||||
'default_warning' => 'The actual server on Hetzner Cloud will NOT be deleted.',
|
||||
];
|
||||
}
|
||||
|
||||
return view('livewire.server.delete', [
|
||||
'checkboxes' => $checkboxes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,17 +118,31 @@ public function checkProxyStatus()
|
|||
|
||||
public function showNotification()
|
||||
{
|
||||
$this->server->refresh();
|
||||
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
|
||||
$forceStop = $this->server->proxy->force_stop ?? false;
|
||||
|
||||
switch ($this->proxyStatus) {
|
||||
case 'running':
|
||||
$this->loadProxyConfiguration();
|
||||
$this->dispatch('success', 'Proxy is running.');
|
||||
break;
|
||||
case 'restarting':
|
||||
$this->dispatch('info', 'Initiating proxy restart.');
|
||||
break;
|
||||
case 'exited':
|
||||
$this->dispatch('info', 'Proxy has exited.');
|
||||
break;
|
||||
case 'stopping':
|
||||
$this->dispatch('info', 'Proxy is stopping.');
|
||||
break;
|
||||
case 'starting':
|
||||
$this->dispatch('info', 'Proxy is starting.');
|
||||
break;
|
||||
case 'unknown':
|
||||
$this->dispatch('info', 'Proxy status is unknown.');
|
||||
break;
|
||||
default:
|
||||
$this->dispatch('info', 'Proxy status updated.');
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
587
app/Livewire/Server/New/ByHetzner.php
Normal file
587
app/Livewire/Server/New/ByHetzner.php
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Server\New;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class ByHetzner extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
// Step tracking
|
||||
public int $current_step = 1;
|
||||
|
||||
// Locked data
|
||||
#[Locked]
|
||||
public Collection $available_tokens;
|
||||
|
||||
#[Locked]
|
||||
public $private_keys;
|
||||
|
||||
#[Locked]
|
||||
public $limit_reached;
|
||||
|
||||
// Step 1: Token selection
|
||||
public ?int $selected_token_id = null;
|
||||
|
||||
// Step 2: Server configuration
|
||||
public array $locations = [];
|
||||
|
||||
public array $images = [];
|
||||
|
||||
public array $serverTypes = [];
|
||||
|
||||
public array $hetznerSshKeys = [];
|
||||
|
||||
public ?string $selected_location = null;
|
||||
|
||||
public ?int $selected_image = null;
|
||||
|
||||
public ?string $selected_server_type = null;
|
||||
|
||||
public array $selectedHetznerSshKeyIds = [];
|
||||
|
||||
public string $server_name = '';
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
||||
public bool $loading_data = false;
|
||||
|
||||
public bool $enable_ipv4 = true;
|
||||
|
||||
public bool $enable_ipv6 = true;
|
||||
|
||||
public ?string $cloud_init_script = null;
|
||||
|
||||
public bool $save_cloud_init_script = false;
|
||||
|
||||
public ?string $cloud_init_script_name = null;
|
||||
|
||||
public ?int $selected_cloud_init_script_id = null;
|
||||
|
||||
#[Locked]
|
||||
public Collection $saved_cloud_init_scripts;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->loadTokens();
|
||||
$this->loadSavedCloudInitScripts();
|
||||
$this->server_name = generate_random_name();
|
||||
if ($this->private_keys->count() > 0) {
|
||||
$this->private_key_id = $this->private_keys->first()->id;
|
||||
}
|
||||
}
|
||||
|
||||
public function loadSavedCloudInitScripts()
|
||||
{
|
||||
$this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get();
|
||||
}
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'tokenAdded' => 'handleTokenAdded',
|
||||
'privateKeyCreated' => 'handlePrivateKeyCreated',
|
||||
'modalClosed' => 'resetSelection',
|
||||
];
|
||||
}
|
||||
|
||||
public function resetSelection()
|
||||
{
|
||||
$this->selected_token_id = null;
|
||||
$this->current_step = 1;
|
||||
$this->cloud_init_script = null;
|
||||
$this->save_cloud_init_script = false;
|
||||
$this->cloud_init_script_name = null;
|
||||
$this->selected_cloud_init_script_id = null;
|
||||
}
|
||||
|
||||
public function loadTokens()
|
||||
{
|
||||
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
->where('provider', 'hetzner')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function handleTokenAdded($tokenId)
|
||||
{
|
||||
// Refresh token list
|
||||
$this->loadTokens();
|
||||
|
||||
// Auto-select the new token
|
||||
$this->selected_token_id = $tokenId;
|
||||
|
||||
// Automatically proceed to next step
|
||||
$this->nextStep();
|
||||
}
|
||||
|
||||
public function handlePrivateKeyCreated($keyId)
|
||||
{
|
||||
// Refresh private keys list
|
||||
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
|
||||
|
||||
// Auto-select the new key
|
||||
$this->private_key_id = $keyId;
|
||||
|
||||
// Clear validation errors for private_key_id
|
||||
$this->resetErrorBag('private_key_id');
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
|
||||
];
|
||||
|
||||
if ($this->current_step === 2) {
|
||||
$rules = array_merge($rules, [
|
||||
'server_name' => ['required', 'string', 'max:253', new ValidHostname],
|
||||
'selected_location' => 'required|string',
|
||||
'selected_image' => 'required|integer',
|
||||
'selected_server_type' => 'required|string',
|
||||
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
|
||||
'selectedHetznerSshKeyIds' => 'nullable|array',
|
||||
'selectedHetznerSshKeyIds.*' => 'integer',
|
||||
'enable_ipv4' => 'required|boolean',
|
||||
'enable_ipv6' => 'required|boolean',
|
||||
'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml],
|
||||
'save_cloud_init_script' => 'boolean',
|
||||
'cloud_init_script_name' => 'nullable|string|max:255',
|
||||
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
|
||||
]);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'selected_token_id.required' => 'Please select a Hetzner token.',
|
||||
'selected_token_id.exists' => 'Selected token not found.',
|
||||
];
|
||||
}
|
||||
|
||||
public function selectToken(int $tokenId)
|
||||
{
|
||||
$this->selected_token_id = $tokenId;
|
||||
}
|
||||
|
||||
private function validateHetznerToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
|
||||
|
||||
return $response->successful();
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getHetznerToken(): string
|
||||
{
|
||||
if ($this->selected_token_id) {
|
||||
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
|
||||
|
||||
return $token ? $token->token : '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function nextStep()
|
||||
{
|
||||
// Validate step 1 - just need a token selected
|
||||
$this->validate([
|
||||
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
$hetznerToken = $this->getHetznerToken();
|
||||
|
||||
if (! $hetznerToken) {
|
||||
return $this->dispatch('error', 'Please select a valid Hetzner token.');
|
||||
}
|
||||
|
||||
// Load Hetzner data
|
||||
$this->loadHetznerData($hetznerToken);
|
||||
|
||||
// Move to step 2
|
||||
$this->current_step = 2;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function previousStep()
|
||||
{
|
||||
$this->current_step = 1;
|
||||
}
|
||||
|
||||
private function loadHetznerData(string $token)
|
||||
{
|
||||
$this->loading_data = true;
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token);
|
||||
|
||||
$this->locations = $hetznerService->getLocations();
|
||||
$this->serverTypes = $hetznerService->getServerTypes();
|
||||
|
||||
// Get images and sort by name
|
||||
$images = $hetznerService->getImages();
|
||||
|
||||
ray('Raw images from Hetzner API', [
|
||||
'total_count' => count($images),
|
||||
'types' => collect($images)->pluck('type')->unique()->values(),
|
||||
'sample' => array_slice($images, 0, 3),
|
||||
]);
|
||||
|
||||
$this->images = collect($images)
|
||||
->filter(function ($image) {
|
||||
// Only system images
|
||||
if (! isset($image['type']) || $image['type'] !== 'system') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out deprecated images
|
||||
if (isset($image['deprecated']) && $image['deprecated'] === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
ray('Filtered images', [
|
||||
'filtered_count' => count($this->images),
|
||||
'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(),
|
||||
]);
|
||||
|
||||
// Load SSH keys from Hetzner
|
||||
$this->hetznerSshKeys = $hetznerService->getSshKeys();
|
||||
|
||||
ray('Hetzner SSH Keys', [
|
||||
'total_count' => count($this->hetznerSshKeys),
|
||||
'keys' => $this->hetznerSshKeys,
|
||||
]);
|
||||
|
||||
$this->loading_data = false;
|
||||
} catch (\Throwable $e) {
|
||||
$this->loading_data = false;
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function getCpuVendorInfo(array $serverType): string|null
|
||||
{
|
||||
$name = strtolower($serverType['name'] ?? '');
|
||||
|
||||
if (str_starts_with($name, 'ccx')) {
|
||||
return 'AMD Milan EPYC™';
|
||||
} elseif (str_starts_with($name, 'cpx')) {
|
||||
return 'AMD EPYC™';
|
||||
} elseif (str_starts_with($name, 'cx')) {
|
||||
return 'Intel® Xeon®';
|
||||
} elseif (str_starts_with($name, 'cax')) {
|
||||
return 'Ampere® Altra®';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getAvailableServerTypesProperty()
|
||||
{
|
||||
ray('Getting available server types', [
|
||||
'selected_location' => $this->selected_location,
|
||||
'total_server_types' => count($this->serverTypes),
|
||||
]);
|
||||
|
||||
if (! $this->selected_location) {
|
||||
return $this->serverTypes;
|
||||
}
|
||||
|
||||
$filtered = collect($this->serverTypes)
|
||||
->filter(function ($type) {
|
||||
if (! isset($type['locations'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$locationNames = collect($type['locations'])->pluck('name')->toArray();
|
||||
|
||||
return in_array($this->selected_location, $locationNames);
|
||||
})
|
||||
->map(function ($serverType) {
|
||||
$serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType);
|
||||
|
||||
return $serverType;
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
ray('Filtered server types', [
|
||||
'selected_location' => $this->selected_location,
|
||||
'filtered_count' => count($filtered),
|
||||
]);
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
public function getAvailableImagesProperty()
|
||||
{
|
||||
ray('Getting available images', [
|
||||
'selected_server_type' => $this->selected_server_type,
|
||||
'total_images' => count($this->images),
|
||||
'images' => $this->images,
|
||||
]);
|
||||
|
||||
if (! $this->selected_server_type) {
|
||||
return $this->images;
|
||||
}
|
||||
|
||||
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
|
||||
|
||||
ray('Server type data', $serverType);
|
||||
|
||||
if (! $serverType || ! isset($serverType['architecture'])) {
|
||||
ray('No architecture in server type, returning all');
|
||||
|
||||
return $this->images;
|
||||
}
|
||||
|
||||
$architecture = $serverType['architecture'];
|
||||
|
||||
$filtered = collect($this->images)
|
||||
->filter(fn ($image) => ($image['architecture'] ?? null) === $architecture)
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
ray('Filtered images', [
|
||||
'architecture' => $architecture,
|
||||
'filtered_count' => count($filtered),
|
||||
]);
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
public function getSelectedServerPriceProperty(): ?string
|
||||
{
|
||||
if (! $this->selected_server_type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
|
||||
|
||||
if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$price = $serverType['prices'][0]['price_monthly']['gross'];
|
||||
|
||||
return '€'.number_format($price, 2);
|
||||
}
|
||||
|
||||
public function updatedSelectedLocation($value)
|
||||
{
|
||||
ray('Location selected', $value);
|
||||
|
||||
// Reset server type and image when location changes
|
||||
$this->selected_server_type = null;
|
||||
$this->selected_image = null;
|
||||
}
|
||||
|
||||
public function updatedSelectedServerType($value)
|
||||
{
|
||||
ray('Server type selected', $value);
|
||||
|
||||
// Reset image when server type changes
|
||||
$this->selected_image = null;
|
||||
}
|
||||
|
||||
public function updatedSelectedImage($value)
|
||||
{
|
||||
ray('Image selected', $value);
|
||||
}
|
||||
|
||||
public function updatedSelectedCloudInitScriptId($value)
|
||||
{
|
||||
if ($value) {
|
||||
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
|
||||
$this->cloud_init_script = $script->script;
|
||||
$this->cloud_init_script_name = $script->name;
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCloudInitScript()
|
||||
{
|
||||
$this->selected_cloud_init_script_id = null;
|
||||
$this->cloud_init_script = '';
|
||||
$this->cloud_init_script_name = '';
|
||||
$this->save_cloud_init_script = false;
|
||||
}
|
||||
|
||||
private function createHetznerServer(string $token): array
|
||||
{
|
||||
$hetznerService = new HetznerService($token);
|
||||
|
||||
// Get the private key and extract public key
|
||||
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
|
||||
|
||||
$publicKey = $privateKey->getPublicKey();
|
||||
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
||||
|
||||
ray('Private Key Info', [
|
||||
'private_key_id' => $this->private_key_id,
|
||||
'sha256_fingerprint' => $privateKey->fingerprint,
|
||||
'md5_fingerprint' => $md5Fingerprint,
|
||||
]);
|
||||
|
||||
// Check if SSH key already exists on Hetzner by comparing MD5 fingerprints
|
||||
$existingSshKeys = $hetznerService->getSshKeys();
|
||||
$existingKey = null;
|
||||
|
||||
ray('Existing SSH Keys on Hetzner', $existingSshKeys);
|
||||
|
||||
foreach ($existingSshKeys as $key) {
|
||||
if ($key['fingerprint'] === $md5Fingerprint) {
|
||||
$existingKey = $key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload SSH key if it doesn't exist
|
||||
if ($existingKey) {
|
||||
$sshKeyId = $existingKey['id'];
|
||||
ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]);
|
||||
} else {
|
||||
$sshKeyName = $privateKey->name;
|
||||
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
|
||||
$sshKeyId = $uploadedKey['id'];
|
||||
ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]);
|
||||
}
|
||||
|
||||
// Normalize server name to lowercase for RFC 1123 compliance
|
||||
$normalizedServerName = strtolower(trim($this->server_name));
|
||||
|
||||
// Prepare SSH keys array: Coolify key + user-selected Hetzner keys
|
||||
$sshKeys = array_merge(
|
||||
[$sshKeyId], // Coolify key (always included)
|
||||
$this->selectedHetznerSshKeyIds // User-selected Hetzner keys
|
||||
);
|
||||
|
||||
// Remove duplicates in case the Coolify key was also selected
|
||||
$sshKeys = array_unique($sshKeys);
|
||||
$sshKeys = array_values($sshKeys); // Re-index array
|
||||
|
||||
// Prepare server creation parameters
|
||||
$params = [
|
||||
'name' => $normalizedServerName,
|
||||
'server_type' => $this->selected_server_type,
|
||||
'image' => $this->selected_image,
|
||||
'location' => $this->selected_location,
|
||||
'start_after_create' => true,
|
||||
'ssh_keys' => $sshKeys,
|
||||
'public_net' => [
|
||||
'enable_ipv4' => $this->enable_ipv4,
|
||||
'enable_ipv6' => $this->enable_ipv6,
|
||||
],
|
||||
];
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($this->cloud_init_script)) {
|
||||
$params['user_data'] = $this->cloud_init_script;
|
||||
}
|
||||
|
||||
ray('Server creation parameters', $params);
|
||||
|
||||
// Create server on Hetzner
|
||||
$hetznerServer = $hetznerService->createServer($params);
|
||||
|
||||
ray('Hetzner server created', $hetznerServer);
|
||||
|
||||
return $hetznerServer;
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
if (Team::serverLimitReached()) {
|
||||
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
|
||||
}
|
||||
|
||||
// Save cloud-init script if requested
|
||||
if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) {
|
||||
$this->authorize('create', CloudInitScript::class);
|
||||
|
||||
CloudInitScript::create([
|
||||
'team_id' => currentTeam()->id,
|
||||
'name' => $this->cloud_init_script_name,
|
||||
'script' => $this->cloud_init_script,
|
||||
]);
|
||||
}
|
||||
|
||||
$hetznerToken = $this->getHetznerToken();
|
||||
|
||||
// Create server on Hetzner
|
||||
$hetznerServer = $this->createHetznerServer($hetznerToken);
|
||||
|
||||
// Determine IP address to use (prefer IPv4, fallback to IPv6)
|
||||
$ipAddress = null;
|
||||
if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
|
||||
} elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
||||
}
|
||||
|
||||
if (! $ipAddress) {
|
||||
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
|
||||
}
|
||||
|
||||
// Create server in Coolify database
|
||||
$server = Server::create([
|
||||
'name' => $this->server_name,
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => currentTeam()->id,
|
||||
'private_key_id' => $this->private_key_id,
|
||||
'cloud_provider_token_id' => $this->selected_token_id,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
return redirect()->route('server.show', $server->uuid);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.new.by-hetzner');
|
||||
}
|
||||
}
|
||||
|
|
@ -67,13 +67,21 @@ class Show extends Component
|
|||
|
||||
public string $serverTimezone;
|
||||
|
||||
public ?string $hetznerServerStatus = null;
|
||||
|
||||
public bool $hetznerServerManuallyStarted = false;
|
||||
|
||||
public bool $isValidating = false;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
'refreshServerShow' => 'refresh',
|
||||
'refreshServer' => '$refresh',
|
||||
"echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted',
|
||||
"echo-private:team.{$teamId},ServerValidated" => 'handleServerValidated',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -138,6 +146,10 @@ public function mount(string $server_uuid)
|
|||
if (! $this->server->isEmpty()) {
|
||||
$this->isBuildServerLocked = true;
|
||||
}
|
||||
// Load saved Hetzner status and validation state
|
||||
$this->hetznerServerStatus = $this->server->hetzner_server_status;
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -218,6 +230,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
|
||||
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
|
||||
$this->serverTimezone = $this->server->settings->server_timezone;
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -361,6 +374,87 @@ public function instantSave()
|
|||
}
|
||||
}
|
||||
|
||||
public function checkHetznerServerStatus(bool $manual = false)
|
||||
{
|
||||
try {
|
||||
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
|
||||
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
|
||||
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
|
||||
|
||||
$this->hetznerServerStatus = $serverData['status'] ?? null;
|
||||
|
||||
// Save status to database without triggering model events
|
||||
if ($this->server->hetzner_server_status !== $this->hetznerServerStatus) {
|
||||
$this->server->hetzner_server_status = $this->hetznerServerStatus;
|
||||
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
|
||||
}
|
||||
if ($manual) {
|
||||
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
|
||||
}
|
||||
|
||||
// If Hetzner server is off but Coolify thinks it's still reachable, update Coolify's state
|
||||
if ($this->hetznerServerStatus === 'off' && $this->server->settings->is_reachable) {
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if ($uptime) {
|
||||
$this->dispatch('success', 'Server is reachable.');
|
||||
$this->server->settings->is_reachable = $this->isReachable = true;
|
||||
$this->server->settings->is_usable = $this->isUsable = true;
|
||||
$this->server->settings->save();
|
||||
ServerReachabilityChanged::dispatch($this->server);
|
||||
} else {
|
||||
$this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function handleServerValidated($event = null)
|
||||
{
|
||||
// Check if event is for this server
|
||||
if ($event && isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh server data
|
||||
$this->server->refresh();
|
||||
$this->syncData();
|
||||
|
||||
// Update validation state
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('refreshServer');
|
||||
}
|
||||
|
||||
public function startHetznerServer()
|
||||
{
|
||||
try {
|
||||
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
|
||||
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
|
||||
$hetznerService->powerOnServer($this->server->hetzner_server_id);
|
||||
|
||||
$this->hetznerServerStatus = 'starting';
|
||||
$this->server->update(['hetzner_server_status' => 'starting']);
|
||||
$this->hetznerServerManuallyStarted = true; // Set flag to trigger auto-validation when running
|
||||
$this->dispatch('success', 'Hetzner server is starting...');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Events\ServerValidated;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
|
@ -63,6 +64,19 @@ public function startValidatingAfterAsking()
|
|||
$this->init();
|
||||
}
|
||||
|
||||
public function retry()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
$this->uptime = null;
|
||||
$this->supported_os_type = null;
|
||||
$this->docker_installed = null;
|
||||
$this->docker_compose_installed = null;
|
||||
$this->docker_version = null;
|
||||
$this->error = null;
|
||||
$this->number_of_tries = 0;
|
||||
$this->init();
|
||||
}
|
||||
|
||||
public function validateConnection()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
|
@ -136,8 +150,12 @@ public function validateDockerVersion()
|
|||
} else {
|
||||
$this->docker_version = $this->server->validateDockerEngineVersion();
|
||||
if ($this->docker_version) {
|
||||
// Mark validation as complete
|
||||
$this->server->update(['is_validating' => false]);
|
||||
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('refreshBoardingIndex');
|
||||
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
|
||||
$this->dispatch('success', 'Server validated, proxy is starting in a moment.');
|
||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||
if (! $proxyShouldRun) {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ private function getAllActiveContainers()
|
|||
|
||||
public function updatedSelectedUuid()
|
||||
{
|
||||
if ($this->selected_uuid === 'default') {
|
||||
// When cleared to default, do nothing (no error message)
|
||||
return;
|
||||
}
|
||||
$this->connectToContainer();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1003,29 +1003,30 @@ public function dirOnServer()
|
|||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false)
|
||||
{
|
||||
$baseDir = $this->generateBaseDir($deployment_uuid);
|
||||
$escapedBaseDir = escapeshellarg($baseDir);
|
||||
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
|
||||
|
||||
if ($this->git_commit_sha !== 'HEAD') {
|
||||
// If shallow clone is enabled and we need a specific commit,
|
||||
// we need to fetch that specific commit with depth=1
|
||||
if ($isShallowCloneEnabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
} else {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
}
|
||||
}
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
// Check if .gitmodules file exists before running submodule commands
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && if [ -f .gitmodules ]; then";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then";
|
||||
if ($public) {
|
||||
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$baseDir}/.gitmodules || true &&";
|
||||
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&";
|
||||
}
|
||||
// Add shallow submodules flag if shallow clone is enabled
|
||||
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
|
||||
$git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi";
|
||||
}
|
||||
if ($this->settings->is_git_lfs_enabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
|
||||
}
|
||||
|
||||
return $git_clone_command;
|
||||
|
|
@ -1063,18 +1064,24 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
|||
$source_html_url_scheme = $url['scheme'];
|
||||
|
||||
if ($this->source->getMorphClass() == 'App\Models\GithubApp') {
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
if ($this->source->is_public) {
|
||||
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
|
||||
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
|
||||
$base_command = "{$base_command} {$this->source->html_url}/{$customRepository}";
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
} else {
|
||||
$github_access_token = generateGithubInstallationToken($this->source);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
||||
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
} else {
|
||||
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
||||
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1099,7 +1106,10 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
|||
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
|
||||
}
|
||||
$private_key = base64_encode($private_key);
|
||||
$base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}";
|
||||
// When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
|
||||
// Replace ' with '\'' to safely escape within single-quoted bash strings
|
||||
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
|
||||
$base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
|
|
@ -1130,7 +1140,8 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
|||
|
||||
if ($this->deploymentType() === 'other') {
|
||||
$fullRepoUrl = $customRepository;
|
||||
$base_command = "{$base_command} {$customRepository}";
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
|
|
@ -1272,7 +1283,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
|
|
@ -1280,14 +1291,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1305,7 +1316,8 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
}
|
||||
if ($this->deploymentType() === 'other') {
|
||||
$fullRepoUrl = $customRepository;
|
||||
$git_clone_command = "{$git_clone_command} {$customRepository} {$baseDir}";
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
|
|
@ -1316,7 +1328,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
|
|
@ -1324,14 +1336,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
33
app/Models/CloudInitScript.php
Normal file
33
app/Models/CloudInitScript.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CloudInitScript extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'name',
|
||||
'script',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'script' => 'encrypted',
|
||||
];
|
||||
}
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam(array $select = ['*'])
|
||||
{
|
||||
$selectArray = collect($select)->concat(['id']);
|
||||
|
||||
return self::whereTeamId(currentTeam()->id)->select($selectArray->all());
|
||||
}
|
||||
}
|
||||
41
app/Models/CloudProviderToken.php
Normal file
41
app/Models/CloudProviderToken.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CloudProviderToken extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'token' => 'encrypted',
|
||||
];
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function servers()
|
||||
{
|
||||
return $this->hasMany(Server::class);
|
||||
}
|
||||
|
||||
public function hasServers(): bool
|
||||
{
|
||||
return $this->servers()->exists();
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam(array $select = ['*'])
|
||||
{
|
||||
$selectArray = collect($select)->concat(['id']);
|
||||
|
||||
return self::whereTeamId(currentTeam()->id)->select($selectArray->all());
|
||||
}
|
||||
|
||||
public function scopeForProvider($query, string $provider)
|
||||
{
|
||||
return $query->where('provider', $provider);
|
||||
}
|
||||
}
|
||||
|
|
@ -289,6 +289,17 @@ public static function generateFingerprint($privateKey)
|
|||
}
|
||||
}
|
||||
|
||||
public static function generateMd5Fingerprint($privateKey)
|
||||
{
|
||||
try {
|
||||
$key = PublicKeyLoader::load($privateKey);
|
||||
|
||||
return $key->getPublicKey()->getFingerprint('md5');
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function fingerprintExists($fingerprint, $excludeId = null)
|
||||
{
|
||||
$query = self::query()
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ protected static function booted()
|
|||
$destination->delete();
|
||||
});
|
||||
$server->settings()->delete();
|
||||
$server->sslCertificates()->delete();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +162,11 @@ protected static function booted()
|
|||
'user',
|
||||
'description',
|
||||
'private_key_id',
|
||||
'cloud_provider_token_id',
|
||||
'team_id',
|
||||
'hetzner_server_id',
|
||||
'hetzner_server_status',
|
||||
'is_validating',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
|
@ -889,6 +894,16 @@ public function privateKey()
|
|||
return $this->belongsTo(PrivateKey::class);
|
||||
}
|
||||
|
||||
public function cloudProviderToken()
|
||||
{
|
||||
return $this->belongsTo(CloudProviderToken::class);
|
||||
}
|
||||
|
||||
public function sslCertificates()
|
||||
{
|
||||
return $this->hasMany(SslCertificate::class);
|
||||
}
|
||||
|
||||
public function muxFilename()
|
||||
{
|
||||
return 'mux_'.$this->uuid;
|
||||
|
|
@ -1327,7 +1342,7 @@ public function generateCaCertificate()
|
|||
isCaCertificate: true,
|
||||
validityDays: 10 * 365
|
||||
);
|
||||
$caCertificate = SslCertificate::where('server_id', $this->id)->where('is_ca_certificate', true)->first();
|
||||
$caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
ray('CA certificate generated', $caCertificate);
|
||||
if ($caCertificate) {
|
||||
$certificateContent = $caCertificate->ssl_certificate;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ protected static function booted()
|
|||
$team->slackNotificationSettings()->create();
|
||||
$team->telegramNotificationSettings()->create();
|
||||
$team->pushoverNotificationSettings()->create();
|
||||
$team->webhookNotificationSettings()->create();
|
||||
});
|
||||
|
||||
static::saving(function ($team) {
|
||||
|
|
@ -258,6 +259,11 @@ public function privateKeys()
|
|||
return $this->hasMany(PrivateKey::class);
|
||||
}
|
||||
|
||||
public function cloudProviderTokens()
|
||||
{
|
||||
return $this->hasMany(CloudProviderToken::class);
|
||||
}
|
||||
|
||||
public function sources()
|
||||
{
|
||||
$sources = collect([]);
|
||||
|
|
@ -307,4 +313,9 @@ public function pushoverNotificationSettings()
|
|||
{
|
||||
return $this->hasOne(PushoverNotificationSettings::class);
|
||||
}
|
||||
|
||||
public function webhookNotificationSettings()
|
||||
{
|
||||
return $this->hasOne(WebhookNotificationSettings::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
app/Models/WebhookNotificationSettings.php
Normal file
64
app/Models/WebhookNotificationSettings.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class WebhookNotificationSettings extends Model
|
||||
{
|
||||
use Notifiable;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
|
||||
'webhook_enabled',
|
||||
'webhook_url',
|
||||
|
||||
'deployment_success_webhook_notifications',
|
||||
'deployment_failure_webhook_notifications',
|
||||
'status_change_webhook_notifications',
|
||||
'backup_success_webhook_notifications',
|
||||
'backup_failure_webhook_notifications',
|
||||
'scheduled_task_success_webhook_notifications',
|
||||
'scheduled_task_failure_webhook_notifications',
|
||||
'docker_cleanup_webhook_notifications',
|
||||
'server_disk_usage_webhook_notifications',
|
||||
'server_reachable_webhook_notifications',
|
||||
'server_unreachable_webhook_notifications',
|
||||
'server_patch_webhook_notifications',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'webhook_enabled' => 'boolean',
|
||||
'webhook_url' => 'encrypted',
|
||||
|
||||
'deployment_success_webhook_notifications' => 'boolean',
|
||||
'deployment_failure_webhook_notifications' => 'boolean',
|
||||
'status_change_webhook_notifications' => 'boolean',
|
||||
'backup_success_webhook_notifications' => 'boolean',
|
||||
'backup_failure_webhook_notifications' => 'boolean',
|
||||
'scheduled_task_success_webhook_notifications' => 'boolean',
|
||||
'scheduled_task_failure_webhook_notifications' => 'boolean',
|
||||
'docker_cleanup_webhook_notifications' => 'boolean',
|
||||
'server_disk_usage_webhook_notifications' => 'boolean',
|
||||
'server_reachable_webhook_notifications' => 'boolean',
|
||||
'server_unreachable_webhook_notifications' => 'boolean',
|
||||
'server_patch_webhook_notifications' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function isEnabled()
|
||||
{
|
||||
return $this->webhook_enabled;
|
||||
}
|
||||
}
|
||||
|
|
@ -185,4 +185,30 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$data = [
|
||||
'success' => false,
|
||||
'message' => 'Deployment failed',
|
||||
'event' => 'deployment_failed',
|
||||
'application_name' => $this->application_name,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'deployment_uuid' => $this->deployment_uuid,
|
||||
'deployment_url' => $this->deployment_url,
|
||||
'project' => data_get($this->application, 'environment.project.name'),
|
||||
'environment' => $this->environment_name,
|
||||
];
|
||||
|
||||
if ($this->preview) {
|
||||
$data['pull_request_id'] = $this->preview->pull_request_id;
|
||||
$data['preview_fqdn'] = $this->preview->fqdn;
|
||||
}
|
||||
|
||||
if ($this->fqdn) {
|
||||
$data['fqdn'] = $this->fqdn;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,4 +205,30 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::successColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$data = [
|
||||
'success' => true,
|
||||
'message' => 'New version successfully deployed',
|
||||
'event' => 'deployment_success',
|
||||
'application_name' => $this->application_name,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'deployment_uuid' => $this->deployment_uuid,
|
||||
'deployment_url' => $this->deployment_url,
|
||||
'project' => data_get($this->application, 'environment.project.name'),
|
||||
'environment' => $this->environment_name,
|
||||
];
|
||||
|
||||
if ($this->preview) {
|
||||
$data['pull_request_id'] = $this->preview->pull_request_id;
|
||||
$data['preview_fqdn'] = $this->preview->fqdn;
|
||||
}
|
||||
|
||||
if ($this->fqdn) {
|
||||
$data['fqdn'] = $this->fqdn;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,4 +113,19 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Application stopped',
|
||||
'event' => 'status_changed',
|
||||
'application_name' => $this->resource_name,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
'url' => $this->resource_url,
|
||||
'project' => data_get($this->resource, 'environment.project.name'),
|
||||
'environment' => $this->environment_name,
|
||||
'fqdn' => $this->fqdn,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
app/Notifications/Channels/WebhookChannel.php
Normal file
37
app/Notifications/Channels/WebhookChannel.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Channels;
|
||||
|
||||
use App\Jobs\SendWebhookJob;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class WebhookChannel
|
||||
{
|
||||
/**
|
||||
* Send the given notification.
|
||||
*/
|
||||
public function send($notifiable, Notification $notification): void
|
||||
{
|
||||
$webhookSettings = $notifiable->webhookNotificationSettings;
|
||||
|
||||
if (! $webhookSettings || ! $webhookSettings->isEnabled() || ! $webhookSettings->webhook_url) {
|
||||
if (isDev()) {
|
||||
ray('Webhook notification skipped - not enabled or no URL configured');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = $notification->toWebhook();
|
||||
|
||||
if (isDev()) {
|
||||
ray('Dispatching webhook notification', [
|
||||
'notification' => get_class($notification),
|
||||
'url' => $webhookSettings->webhook_url,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url);
|
||||
}
|
||||
}
|
||||
|
|
@ -102,4 +102,22 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::warningColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$data = [
|
||||
'success' => true,
|
||||
'message' => 'Resource restarted automatically',
|
||||
'event' => 'container_restarted',
|
||||
'container_name' => $this->name,
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
];
|
||||
|
||||
if ($this->url) {
|
||||
$data['url'] = $this->url;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,4 +102,22 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$data = [
|
||||
'success' => false,
|
||||
'message' => 'Resource stopped unexpectedly',
|
||||
'event' => 'container_stopped',
|
||||
'container_name' => $this->name,
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
];
|
||||
|
||||
if ($this->url) {
|
||||
$data['url'] = $this->url;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,4 +88,21 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid;
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Database backup failed',
|
||||
'event' => 'backup_failed',
|
||||
'database_name' => $this->name,
|
||||
'database_uuid' => $this->database->uuid,
|
||||
'database_type' => $this->database_name,
|
||||
'frequency' => $this->frequency,
|
||||
'error_output' => $this->output,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,4 +85,20 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::successColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Database backup successful',
|
||||
'event' => 'backup_success',
|
||||
'database_name' => $this->name,
|
||||
'database_uuid' => $this->database->uuid,
|
||||
'database_type' => $this->database_name,
|
||||
'frequency' => $this->frequency,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,4 +113,27 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::warningColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid;
|
||||
|
||||
$data = [
|
||||
'success' => true,
|
||||
'message' => 'Database backup succeeded locally, S3 upload failed',
|
||||
'event' => 'backup_success_with_s3_warning',
|
||||
'database_name' => $this->name,
|
||||
'database_uuid' => $this->database->uuid,
|
||||
'database_type' => $this->database_name,
|
||||
'frequency' => $this->frequency,
|
||||
's3_error' => $this->s3_error,
|
||||
'url' => $url,
|
||||
];
|
||||
|
||||
if ($this->s3_storage_url) {
|
||||
$data['s3_storage_url'] = $this->s3_storage_url;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,4 +114,28 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$data = [
|
||||
'success' => false,
|
||||
'message' => 'Scheduled task failed',
|
||||
'event' => 'task_failed',
|
||||
'task_name' => $this->task->name,
|
||||
'task_uuid' => $this->task->uuid,
|
||||
'output' => $this->output,
|
||||
];
|
||||
|
||||
if ($this->task->application) {
|
||||
$data['application_uuid'] = $this->task->application->uuid;
|
||||
} elseif ($this->task->service) {
|
||||
$data['service_uuid'] = $this->task->service->uuid;
|
||||
}
|
||||
|
||||
if ($this->url) {
|
||||
$data['url'] = $this->url;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,4 +105,28 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::successColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$data = [
|
||||
'success' => true,
|
||||
'message' => 'Scheduled task succeeded',
|
||||
'event' => 'task_success',
|
||||
'task_name' => $this->task->name,
|
||||
'task_uuid' => $this->task->uuid,
|
||||
'output' => $this->output,
|
||||
];
|
||||
|
||||
if ($this->task->application) {
|
||||
$data['application_uuid'] = $this->task->application->uuid;
|
||||
} elseif ($this->task->service) {
|
||||
$data['service_uuid'] = $this->task->service->uuid;
|
||||
}
|
||||
|
||||
if ($this->url) {
|
||||
$data['url'] = $this->url;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,4 +66,19 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$url = base_url().'/server/'.$this->server->uuid;
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Docker cleanup job failed',
|
||||
'event' => 'docker_cleanup_failed',
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'error_message' => $this->message,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,4 +66,19 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::successColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$url = base_url().'/server/'.$this->server->uuid;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Docker cleanup job succeeded',
|
||||
'event' => 'docker_cleanup_success',
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'cleanup_message' => $this->message,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
app/Notifications/Server/HetznerDeletionFailed.php
Normal file
71
app/Notifications/Server/HetznerDeletionFailed.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Server;
|
||||
|
||||
use App\Notifications\CustomEmailNotification;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class HetznerDeletionFailed extends CustomEmailNotification
|
||||
{
|
||||
public function __construct(public int $hetznerServerId, public int $teamId, public string $errorMessage)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
ray('hello');
|
||||
ray($notifiable);
|
||||
|
||||
return $notifiable->getEnabledChannels('hetzner_deletion_failed');
|
||||
}
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$mail = new MailMessage;
|
||||
$mail->subject("Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}");
|
||||
$mail->view('emails.hetzner-deletion-failed', [
|
||||
'hetznerServerId' => $this->hetznerServerId,
|
||||
'errorMessage' => $this->errorMessage,
|
||||
]);
|
||||
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public function toDiscord(): DiscordMessage
|
||||
{
|
||||
return new DiscordMessage(
|
||||
title: ':cross_mark: Coolify: [ACTION REQUIRED] Failed to delete Hetzner server',
|
||||
description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\n**Error:** {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.",
|
||||
color: DiscordMessage::errorColor(),
|
||||
);
|
||||
}
|
||||
|
||||
public function toTelegram(): array
|
||||
{
|
||||
return [
|
||||
'message' => "Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.",
|
||||
];
|
||||
}
|
||||
|
||||
public function toPushover(): PushoverMessage
|
||||
{
|
||||
return new PushoverMessage(
|
||||
title: 'Hetzner Server Deletion Failed',
|
||||
level: 'error',
|
||||
message: "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check and manually delete if needed.",
|
||||
);
|
||||
}
|
||||
|
||||
public function toSlack(): SlackMessage
|
||||
{
|
||||
return new SlackMessage(
|
||||
title: 'Coolify: [ACTION REQUIRED] Hetzner Server Deletion Failed',
|
||||
description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.",
|
||||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -88,4 +88,18 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'High disk usage detected',
|
||||
'event' => 'high_disk_usage',
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'disk_usage' => $this->disk_usage,
|
||||
'threshold' => $this->server_disk_usage_notification_threshold,
|
||||
'url' => base_url().'/server/'.$this->server->uuid,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,4 +74,18 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::successColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$url = base_url().'/server/'.$this->server->uuid;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Server revived',
|
||||
'event' => 'server_reachable',
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,4 +345,47 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
// Handle error case
|
||||
if (isset($this->patchData['error'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Failed to check patches',
|
||||
'event' => 'server_patch_check_error',
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'os_id' => $this->patchData['osId'] ?? 'unknown',
|
||||
'package_manager' => $this->patchData['package_manager'] ?? 'unknown',
|
||||
'error' => $this->patchData['error'],
|
||||
'url' => $this->serverUrl,
|
||||
];
|
||||
}
|
||||
|
||||
$totalUpdates = $this->patchData['total_updates'] ?? 0;
|
||||
$updates = $this->patchData['updates'] ?? [];
|
||||
|
||||
// Check for critical packages
|
||||
$criticalPackages = collect($updates)->filter(function ($update) {
|
||||
return str_contains(strtolower($update['package']), 'docker') ||
|
||||
str_contains(strtolower($update['package']), 'kernel') ||
|
||||
str_contains(strtolower($update['package']), 'openssh') ||
|
||||
str_contains(strtolower($update['package']), 'ssl');
|
||||
});
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Server patches available',
|
||||
'event' => 'server_patch_check',
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'total_updates' => $totalUpdates,
|
||||
'os_id' => $this->patchData['osId'] ?? 'unknown',
|
||||
'package_manager' => $this->patchData['package_manager'] ?? 'unknown',
|
||||
'updates' => $updates,
|
||||
'critical_packages_count' => $criticalPackages->count(),
|
||||
'url' => $this->serverUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,4 +82,18 @@ public function toSlack(): SlackMessage
|
|||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$url = base_url().'/server/'.$this->server->uuid;
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Server unreachable',
|
||||
'event' => 'server_unreachable',
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Notifications\Channels\PushoverChannel;
|
||||
use App\Notifications\Channels\SlackChannel;
|
||||
use App\Notifications\Channels\TelegramChannel;
|
||||
use App\Notifications\Channels\WebhookChannel;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
|
|
@ -36,6 +37,7 @@ public function via(object $notifiable): array
|
|||
'telegram' => [TelegramChannel::class],
|
||||
'slack' => [SlackChannel::class],
|
||||
'pushover' => [PushoverChannel::class],
|
||||
'webhook' => [WebhookChannel::class],
|
||||
default => [],
|
||||
};
|
||||
} else {
|
||||
|
|
@ -110,4 +112,14 @@ public function toSlack(): SlackMessage
|
|||
description: 'This is a test Slack notification from Coolify.'
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'This is a test webhook notification from Coolify.',
|
||||
'event' => 'test',
|
||||
'url' => base_url(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
app/Policies/CloudInitScriptPolicy.php
Normal file
65
app/Policies/CloudInitScriptPolicy.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\User;
|
||||
|
||||
class CloudInitScriptPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, CloudInitScript $cloudInitScript): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, CloudInitScript $cloudInitScript): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, CloudInitScript $cloudInitScript): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, CloudInitScript $cloudInitScript): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, CloudInitScript $cloudInitScript): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
}
|
||||
65
app/Policies/CloudProviderTokenPolicy.php
Normal file
65
app/Policies/CloudProviderTokenPolicy.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\User;
|
||||
|
||||
class CloudProviderTokenPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ class AuthServiceProvider extends ServiceProvider
|
|||
\App\Models\TelegramNotificationSettings::class => \App\Policies\NotificationPolicy::class,
|
||||
\App\Models\SlackNotificationSettings::class => \App\Policies\NotificationPolicy::class,
|
||||
\App\Models\PushoverNotificationSettings::class => \App\Policies\NotificationPolicy::class,
|
||||
\App\Models\WebhookNotificationSettings::class => \App\Policies\NotificationPolicy::class,
|
||||
|
||||
// API Token policy
|
||||
\Laravel\Sanctum\PersonalAccessToken::class => \App\Policies\ApiTokenPolicy::class,
|
||||
|
|
|
|||
55
app/Rules/ValidCloudInitYaml.php
Normal file
55
app/Rules/ValidCloudInitYaml.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Symfony\Component\Yaml\Exception\ParseException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ValidCloudInitYaml implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* Validates that the cloud-init script is either:
|
||||
* - Valid YAML format (for cloud-config)
|
||||
* - Valid bash script (starting with #!)
|
||||
* - Empty/null (optional field)
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$script = trim($value);
|
||||
|
||||
// If it's a bash script (starts with shebang), skip YAML validation
|
||||
if (str_starts_with($script, '#!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a cloud-config file (starts with #cloud-config), validate YAML
|
||||
if (str_starts_with($script, '#cloud-config')) {
|
||||
// Remove the #cloud-config header and validate the rest as YAML
|
||||
$yamlContent = preg_replace('/^#cloud-config\s*/m', '', $script, 1);
|
||||
|
||||
try {
|
||||
Yaml::parse($yamlContent);
|
||||
} catch (ParseException $e) {
|
||||
$fail('The :attribute must be valid YAML format. Error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If it doesn't start with #! or #cloud-config, try to parse as YAML
|
||||
// (some users might omit the #cloud-config header)
|
||||
try {
|
||||
Yaml::parse($script);
|
||||
} catch (ParseException $e) {
|
||||
$fail('The :attribute must be either a valid bash script (starting with #!) or valid cloud-config YAML. YAML parse error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/Rules/ValidHostname.php
Normal file
114
app/Rules/ValidHostname.php
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ValidHostname implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* Validates hostname according to RFC 1123:
|
||||
* - Must be 1-253 characters total
|
||||
* - Each label (segment between dots) must be 1-63 characters
|
||||
* - Labels can contain lowercase letters (a-z), digits (0-9), and hyphens (-)
|
||||
* - Labels cannot start or end with a hyphen
|
||||
* - Labels cannot be all numeric
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostname = trim($value);
|
||||
|
||||
// Check total length (RFC 1123: max 253 characters)
|
||||
if (strlen($hostname) > 253) {
|
||||
$fail('The :attribute must not exceed 253 characters.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for dangerous shell metacharacters
|
||||
$dangerousChars = [
|
||||
';', '|', '&', '$', '`', '(', ')', '{', '}',
|
||||
'<', '>', '\n', '\r', '\0', '"', "'", '\\',
|
||||
'!', '*', '?', '[', ']', '~', '^', ':', '#',
|
||||
'@', '%', '=', '+', ',', ' ',
|
||||
];
|
||||
|
||||
foreach ($dangerousChars as $char) {
|
||||
if (str_contains($hostname, $char)) {
|
||||
try {
|
||||
$logData = [
|
||||
'hostname' => $hostname,
|
||||
'character' => $char,
|
||||
];
|
||||
|
||||
if (function_exists('request') && app()->has('request')) {
|
||||
$logData['ip'] = request()->ip();
|
||||
}
|
||||
|
||||
if (function_exists('auth') && app()->has('auth')) {
|
||||
$logData['user_id'] = auth()->id();
|
||||
}
|
||||
|
||||
Log::warning('Hostname validation failed - dangerous character', $logData);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore errors when facades are not available (e.g., in unit tests)
|
||||
}
|
||||
|
||||
$fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Additional validation: hostname should not start or end with a dot
|
||||
if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) {
|
||||
$fail('The :attribute cannot start or end with a dot.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for consecutive dots
|
||||
if (str_contains($hostname, '..')) {
|
||||
$fail('The :attribute cannot contain consecutive dots.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Split into labels (segments between dots)
|
||||
$labels = explode('.', $hostname);
|
||||
|
||||
foreach ($labels as $label) {
|
||||
// Check label length (RFC 1123: max 63 characters per label)
|
||||
if (strlen($label) < 1 || strlen($label) > 63) {
|
||||
$fail('The :attribute contains an invalid label. Each segment must be 1-63 characters.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if label starts or ends with hyphen
|
||||
if (str_starts_with($label, '-') || str_ends_with($label, '-')) {
|
||||
$fail('The :attribute contains an invalid label. Labels cannot start or end with a hyphen.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if label contains only valid characters (lowercase letters, digits, hyphens)
|
||||
if (! preg_match('/^[a-z0-9-]+$/', $label)) {
|
||||
$fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC 1123 allows labels to be all numeric (unlike RFC 952)
|
||||
// So we don't need to check for all-numeric labels
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue